Skill

csharp-conventions

This skill should be used when working on C# or .NET projects, writing C# code, reviewing C# code, or applying modern C# 12+ idioms and patterns.

From ccfg-csharp
Install
1
Run in your terminal
$
npx claudepluginhub jsamuelsen11/claude-config --plugin ccfg-csharp
Tool Access

This skill uses the workspace's default tool permissions.

Skill Content

C# Code Style and Idiomatic Patterns

This skill defines comprehensive conventions for writing modern C# 12+ code following .NET community standards, Microsoft design guidelines, and idiomatic patterns for .NET 8+ projects.

File-Scoped Namespaces

Always Use File-Scoped Namespaces

File-scoped namespaces reduce indentation by one level and are the standard in modern .NET.

// CORRECT: File-scoped namespace
namespace Catalog.Domain.Models;

public class Product
{
    public required Guid Id { get; init; }
    public required string Name { get; set; }
}
// WRONG: Block-scoped namespace adds unnecessary nesting
namespace Catalog.Domain.Models
{
    public class Product
    {
        public required Guid Id { get; init; }
        public required string Name { get; set; }
    }
}

Records for DTOs and Value Objects

Use Records for Immutable Data Carriers

Prefer records over classes for DTOs, API responses, events, and value objects. Records provide structural equality, with expressions, and deconstruction automatically.

// CORRECT: Record for an API response
public record ProductResponse(
    Guid Id,
    string Name,
    decimal Price,
    string Category,
    DateTimeOffset CreatedAt);
// WRONG: Full class for a simple data carrier
public class ProductResponse
{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public string Category { get; set; } = string.Empty;
    public DateTimeOffset CreatedAt { get; set; }
}

Use Record Compact Constructors for Validation

// CORRECT: Compact constructor validates parameters
public record OrderItem(string ProductId, int Quantity, decimal UnitPrice)
{
    public OrderItem
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(ProductId);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(Quantity);
        ArgumentOutOfRangeException.ThrowIfNegative(UnitPrice);
    }
}
// WRONG: Canonical constructor duplicates field assignments
public record OrderItem(string ProductId, int Quantity, decimal UnitPrice)
{
    public OrderItem(string productId, int quantity, decimal unitPrice)
        : this(productId, quantity, unitPrice)
    {
        if (quantity <= 0) throw new ArgumentException("Invalid quantity");
    }
}

When Not to Use Records

Do not use records for:

  • EF Core entities (records have reference equality issues with change tracking)
  • Classes with complex mutable state
  • Classes that need inheritance beyond simple hierarchies
// CORRECT: EF Core entity as a class
public class Product
{
    public required Guid Id { get; init; }
    public required string Name { get; set; }
    public decimal Price { get; set; }
    public byte[] RowVersion { get; set; } = [];
}

Primary Constructors

Use Primary Constructors for Dependency Injection

Primary constructors (C# 12) eliminate constructor boilerplate for service classes.

// CORRECT: Primary constructor for DI
public class ProductService(
    IProductRepository repository,
    ILogger<ProductService> logger)
{
    public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct)
    {
        logger.LogDebug("Fetching product {ProductId}", id);
        return await repository.FindByIdAsync(id, ct);
    }
}
// WRONG: Manual constructor boilerplate
public class ProductService
{
    private readonly IProductRepository _repository;
    private readonly ILogger<ProductService> _logger;

    public ProductService(
        IProductRepository repository,
        ILogger<ProductService> logger)
    {
        _repository = repository;
        _logger = logger;
    }

    public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct)
    {
        _logger.LogDebug("Fetching product {ProductId}", id);
        return await _repository.FindByIdAsync(id, ct);
    }
}

Nullable Reference Types

Always Enable Nullable and Handle Nulls Explicitly

Enable nullable reference types project-wide and use annotations to communicate intent.

// CORRECT: Explicit nullable handling
public async Task<UserResponse?> FindByEmailAsync(
    string email, CancellationToken ct)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(email);

    var user = await repository.FindByEmailAsync(email, ct);
    return user is null ? null : MapToResponse(user);
}
// WRONG: Ignoring nullable with null-forgiving operator
public async Task<UserResponse> FindByEmailAsync(string email, CancellationToken ct)
{
    var user = await repository.FindByEmailAsync(email, ct);
    return MapToResponse(user!); // Hides potential NullReferenceException
}

Prefer Pattern Matching for Null Checks

// CORRECT: Pattern matching with is
if (result is { Value: > 0 } positiveResult)
{
    Process(positiveResult);
}

if (user is not null)
{
    SendNotification(user);
}
// WRONG: Comparison operators for null checks
if (result != null && result.Value > 0)
{
    Process(result);
}

if (user != null)
{
    SendNotification(user);
}

Use Guard Clause Methods

// CORRECT: .NET 8 guard clause methods
public void Process(Order order, string reason)
{
    ArgumentNullException.ThrowIfNull(order);
    ArgumentException.ThrowIfNullOrWhiteSpace(reason);
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(order.Total);
}
// WRONG: Manual null check with throw
public void Process(Order order, string reason)
{
    if (order == null) throw new ArgumentNullException(nameof(order));
    if (string.IsNullOrWhiteSpace(reason))
        throw new ArgumentException("Reason is required", nameof(reason));
}

Async/Await Rules

Always Propagate CancellationToken

Every async method that does I/O must accept and pass through a CancellationToken.

// CORRECT: CancellationToken propagated through chain
public async Task<ProductResponse> GetProductAsync(
    Guid id, CancellationToken ct)
{
    var product = await repository.FindByIdAsync(id, ct);
    var reviews = await reviewService.GetReviewsAsync(id, ct);
    return MapToResponse(product, reviews);
}
// WRONG: CancellationToken not passed to downstream calls
public async Task<ProductResponse> GetProductAsync(Guid id)
{
    var product = await repository.FindByIdAsync(id, default);
    var reviews = await reviewService.GetReviewsAsync(id, default);
    return MapToResponse(product, reviews);
}

Use ValueTask for Hot Paths with Synchronous Results

// CORRECT: ValueTask when result is often available synchronously
public ValueTask<Product?> GetByIdAsync(Guid id, CancellationToken ct)
{
    if (cache.TryGetValue(id, out Product? cached))
    {
        return ValueTask.FromResult(cached);
    }
    return new ValueTask<Product?>(LoadFromDatabaseAsync(id, ct));
}
// WRONG: Task when result is frequently synchronous
public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct)
{
    if (cache.TryGetValue(id, out Product? cached))
    {
        return cached; // Allocates a Task unnecessarily
    }
    return await LoadFromDatabaseAsync(id, ct);
}

Never Use async void

// CORRECT: async Task for async methods
public async Task HandleEventAsync(OrderPlacedEvent e, CancellationToken ct)
{
    await notificationService.SendAsync(e.CustomerId, ct);
}
// WRONG: async void loses exceptions and cannot be awaited
public async void HandleEvent(OrderPlacedEvent e)
{
    await notificationService.SendAsync(e.CustomerId, default);
}

Avoid .Result and .Wait()

// CORRECT: await for async results
var product = await repository.FindByIdAsync(id, ct);
// WRONG: Blocking on async code causes deadlocks
var product = repository.FindByIdAsync(id, ct).Result;
var product2 = repository.FindByIdAsync(id, ct).GetAwaiter().GetResult();

LINQ Rules

Prefer Method Syntax for Most Operations

// CORRECT: Method syntax for common operations
var activeProducts = products
    .Where(p => p.Status == ProductStatus.Active)
    .OrderBy(p => p.Name)
    .Select(p => new ProductListItem(p.Id, p.Name, p.Price))
    .ToList();
// WRONG: Query syntax for simple operations
var activeProducts = (
    from p in products
    where p.Status == ProductStatus.Active
    orderby p.Name
    select new ProductListItem(p.Id, p.Name, p.Price)
).ToList();

Use Query Syntax for Joins

// CORRECT: Query syntax makes joins readable
var results =
    from product in products
    join category in categories on product.CategoryId equals category.Id
    where product.Price > 100
    select new { product.Name, category.Name };
// WRONG: Method syntax for complex joins
var results = products
    .Join(categories,
        p => p.CategoryId,
        c => c.Id,
        (p, c) => new { p, c })
    .Where(x => x.p.Price > 100)
    .Select(x => new { x.p.Name, CategoryName = x.c.Name });

Avoid Materializing Prematurely

// CORRECT: Defer execution until needed
var query = dbContext.Products
    .Where(p => p.Price > 100)
    .OrderBy(p => p.Name);

// Apply pagination at database level
var results = await query
    .Skip(page * pageSize)
    .Take(pageSize)
    .ToListAsync(ct);
// WRONG: Materializing before filtering loads entire table
var allProducts = await dbContext.Products.ToListAsync(ct);
var results = allProducts
    .Where(p => p.Price > 100)
    .OrderBy(p => p.Name)
    .Skip(page * pageSize)
    .Take(pageSize)
    .ToList();

Dependency Injection Rules

Register Services with Correct Lifetimes

// CORRECT: Appropriate lifetimes
services.AddSingleton<ISystemClock, SystemClock>();  // Stateless, thread-safe
services.AddScoped<IProductRepository, ProductRepository>(); // Per-request, DbContext
services.AddTransient<IProductValidator, ProductValidator>(); // Lightweight, no state
// WRONG: DbContext-dependent service as singleton
services.AddSingleton<IProductRepository, ProductRepository>(); // DbContext is scoped!

Use IOptions Pattern for Configuration

// CORRECT: IOptions pattern with validation
services.AddOptionsWithValidateOnStart<CatalogOptions>()
    .Bind(configuration.GetSection("Catalog"))
    .ValidateDataAnnotations();
// WRONG: Reading configuration directly in services
public class ProductService
{
    private readonly string _connectionString;

    public ProductService(IConfiguration config)
    {
        _connectionString = config["ConnectionStrings:Default"]!;
    }
}

Naming Conventions

PascalCase for Public Members

// CORRECT
public class ProductService
{
    public async Task<Product> GetByIdAsync(Guid id, CancellationToken ct) { }
    public string DisplayName { get; set; } = string.Empty;
    public const int MaxPageSize = 100;
}
// WRONG
public class productService
{
    public async Task<Product> getById(Guid id, CancellationToken ct) { }
    public string displayName { get; set; } = string.Empty;
    public const int MAX_PAGE_SIZE = 100;
}

_camelCase for Private Fields

// CORRECT: Underscore prefix for private fields
public class OrderProcessor
{
    private readonly IOrderRepository _orderRepository;
    private readonly ILogger<OrderProcessor> _logger;
    private int _retryCount;
}
// WRONG: No prefix or other conventions
public class OrderProcessor
{
    private readonly IOrderRepository orderRepository;
    private readonly ILogger<OrderProcessor> m_logger;
    private int RetryCount;
}

I Prefix for Interfaces

// CORRECT
public interface IProductRepository { }
public interface IOrderService { }
// WRONG
public interface ProductRepository { }
public interface OrderServiceInterface { }

Async Suffix for Async Methods

// CORRECT
public async Task<Product> GetByIdAsync(Guid id, CancellationToken ct) { }
public async Task DeleteAsync(Guid id, CancellationToken ct) { }
// WRONG
public async Task<Product> GetById(Guid id, CancellationToken ct) { }
public async Task Delete(Guid id, CancellationToken ct) { }

Warning Suppression Rules

Never Suppress Warnings with Pragmas

Code must compile cleanly with -warnaserror. Never suppress warnings to make the build pass.

// CORRECT: Fix the nullable warning
public string GetDisplayName(User? user)
{
    return user?.DisplayName ?? "Unknown";
}
// WRONG: Suppressing nullable warning
#pragma warning disable CS8602
public string GetDisplayName(User? user)
{
    return user.DisplayName; // NullReferenceException at runtime
}
#pragma warning restore CS8602

Never Use [SuppressMessage]

// CORRECT: Add the null check
public void Process(Order order)
{
    ArgumentNullException.ThrowIfNull(order);
    // process order
}
// WRONG: Suppressing the analyzer
[SuppressMessage("Usage", "CA1062:Validate arguments of public methods")]
public void Process(Order order)
{
    // Missing null check
}

Acceptable Suppression Location

The only place warning suppressions are acceptable is in .editorconfig for project-wide policy decisions made by the team:

# .editorconfig - project-wide policy
dotnet_diagnostic.CA2007.severity = none

Collection Expressions

Use Collection Expressions for Initialization

// CORRECT: Collection expression (C# 12)
List<string> names = ["Alice", "Bob", "Charlie"];
int[] numbers = [1, 2, 3, 4, 5];
IReadOnlyList<string> empty = [];
// WRONG: Verbose initialization
List<string> names = new List<string> { "Alice", "Bob", "Charlie" };
int[] numbers = new int[] { 1, 2, 3, 4, 5 };
IReadOnlyList<string> empty = new List<string>();

Use Spread Operator for Combining Collections

// CORRECT: Spread operator
List<string> combined = [..baseItems, ..additionalItems, "extra"];
// WRONG: Manual concatenation
var combined = baseItems.Concat(additionalItems).Append("extra").ToList();

Pattern Matching

Prefer Switch Expressions Over Switch Statements

// CORRECT: Switch expression
public string GetStatusLabel(OrderStatus status) => status switch
{
    OrderStatus.Pending => "Pending Review",
    OrderStatus.Confirmed => "Confirmed",
    OrderStatus.Shipped => "In Transit",
    OrderStatus.Delivered => "Delivered",
    OrderStatus.Cancelled => "Cancelled",
    _ => throw new ArgumentOutOfRangeException(nameof(status))
};
// WRONG: Switch statement with returns
public string GetStatusLabel(OrderStatus status)
{
    switch (status)
    {
        case OrderStatus.Pending: return "Pending Review";
        case OrderStatus.Confirmed: return "Confirmed";
        case OrderStatus.Shipped: return "In Transit";
        case OrderStatus.Delivered: return "Delivered";
        case OrderStatus.Cancelled: return "Cancelled";
        default: throw new ArgumentOutOfRangeException(nameof(status));
    }
}

Use Property Patterns for Object Matching

// CORRECT: Property patterns
if (response is { StatusCode: >= 200 and < 300, Content.Length: > 0 })
{
    ProcessResponse(response);
}
// WRONG: Multiple conditions
if (response != null && response.StatusCode >= 200
    && response.StatusCode < 300 && response.Content != null
    && response.Content.Length > 0)
{
    ProcessResponse(response);
}

Error Handling

Throw Specific Exception Types

// CORRECT: Specific, meaningful exception types
public async Task<Product> GetByIdAsync(Guid id, CancellationToken ct)
{
    return await repository.FindByIdAsync(id, ct)
        ?? throw new ProductNotFoundException(id);
}
// WRONG: Generic exceptions
public async Task<Product> GetByIdAsync(Guid id, CancellationToken ct)
{
    var product = await repository.FindByIdAsync(id, ct);
    if (product == null)
        throw new Exception($"Product {id} not found");
    return product;
}

Use IExceptionHandler for Global Error Handling

// CORRECT: IExceptionHandler (.NET 8+)
public class GlobalExceptionHandler(
    ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext context, Exception exception, CancellationToken ct)
    {
        logger.LogError(exception, "Unhandled exception");
        context.Response.StatusCode = 500;
        await context.Response.WriteAsJsonAsync(
            new ProblemDetails { Status = 500, Title = "Internal Server Error" }, ct);
        return true;
    }
}
// WRONG: Try-catch in every controller action
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    try
    {
        var product = await service.GetByIdAsync(id, default);
        return Ok(product);
    }
    catch (Exception ex)
    {
        return StatusCode(500, new { error = ex.Message });
    }
}

Logging

Use Structured Logging

// CORRECT: Structured logging with message templates
logger.LogInformation(
    "Processing order {OrderId} for customer {CustomerId}",
    order.Id, order.CustomerId);
// WRONG: String interpolation in log messages (defeats structured logging)
logger.LogInformation(
    $"Processing order {order.Id} for customer {order.CustomerId}");

Use Appropriate Log Levels

// CORRECT: Appropriate levels
logger.LogDebug("Cache hit for product {ProductId}", id);
logger.LogInformation("Order {OrderId} placed successfully", order.Id);
logger.LogWarning("Retry attempt {Attempt} for payment {PaymentId}", attempt, paymentId);
logger.LogError(exception, "Failed to process order {OrderId}", orderId);
logger.LogCritical(exception, "Database connection lost");
// WRONG: Everything at Information level
logger.LogInformation("Cache hit for product {ProductId}", id); // Too noisy
logger.LogInformation(exception.ToString()); // Loses structure
Stats
Parent Repo Stars0
Parent Repo Forks0
Last CommitFeb 10, 2026