From ccfg-csharp
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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ccfg-csharp:csharp-conventionsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill defines comprehensive conventions for writing modern C# 12+ code following .NET community
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 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; }
}
}
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; }
}
// 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");
}
}
Do not use records for:
// 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 (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);
}
}
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
}
// 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);
}
// 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));
}
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);
}
// 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);
}
// 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);
}
// 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();
// 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();
// 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 });
// 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();
// 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!
// 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"]!;
}
}
// 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;
}
// 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;
}
// CORRECT
public interface IProductRepository { }
public interface IOrderService { }
// WRONG
public interface ProductRepository { }
public interface OrderServiceInterface { }
// 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) { }
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
// 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
}
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
// 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>();
// CORRECT: Spread operator
List<string> combined = [..baseItems, ..additionalItems, "extra"];
// WRONG: Manual concatenation
var combined = baseItems.Concat(additionalItems).Append("extra").ToList();
// 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));
}
}
// 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);
}
// 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;
}
// 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 });
}
}
// 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}");
// 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
npx claudepluginhub jsamuelsen11/claude-config --plugin ccfg-csharpWrite modern, high-performance C# code using records, pattern matching, value objects, async/await, Span<T>/Memory<T>, and best-practice API design patterns. Emphasizes functional-style programming with C# 12+ features. Use when writing new C# code or refactoring existing code, designing public APIs for libraries or services, optimizing performance-critical code paths, or building async/await-heavy applications.
Provides idiomatic C# and .NET patterns for code review and development: records, dependency injection, async/await, cancellation tokens, and parallel async operations.
Guides use of modern C# language features for .NET 10 and C# 14, including primary constructors, collection expressions, records, pattern matching, spans, and the field keyword.