C# Conventions
Records & Data Types
- Use records for immutable data models — get equality, hashing, and deconstruction
- Use
init properties for immutable object construction
- Enable nullable reference types (
<Nullable>enable</Nullable>) project-wide — catches null reference bugs at compile time
- Prefer
record struct for small value types to avoid heap allocations
// BAD: mutable class with manual equality
public class UserDto {
public string Name { get; set; }
public string Email { get; set; }
}
// GOOD: immutable record with structural equality
public record UserDto(string Name, string Email);
// GOOD: record with validation
public record OrderId {
public string Value { get; }
public OrderId(string value) {
ArgumentException.ThrowIfNullOrWhiteSpace(value);
Value = value;
}
}
Async Patterns
- Use
async/await for all I/O operations — never block with .Result or .Wait(); blocking causes thread pool starvation and deadlocks
- Use
ConfigureAwait(false) in library code to avoid deadlocks
- Use
IAsyncDisposable with await using for async resource cleanup
- Prefer
ValueTask<T> over Task<T> for hot paths that often complete synchronously
// GOOD: async disposal pattern
await using var connection = await CreateConnectionAsync();
await using var transaction = await connection.BeginTransactionAsync();
try {
await ExecuteCommandsAsync(transaction);
await transaction.CommitAsync();
} catch {
await transaction.RollbackAsync();
throw;
}
LINQ & Collections
- Use LINQ for collection transformations — no manual loops for map/filter/reduce
- Prefer method syntax for complex queries, query syntax for joins
- Use
IReadOnlyList<T> and IReadOnlyDictionary<K,V> in public APIs — prevents callers from mutating your internal state
- Avoid materializing queries prematurely — defer
.ToList() until needed; premature materialization wastes memory on unused results
Nullable Reference Types
- Annotate all public APIs with nullability — no ambiguous references
- Use
! (null-forgiving) only when you can prove the value is non-null
- Use pattern matching for null checks:
if (value is { } valid)
- Return empty collections instead of null for collection-typed returns
Primary Constructors (C# 12+)
- Use primary constructors on service classes to reduce DI boilerplate — parameters are available throughout the class body
- Capture primary constructor parameters in readonly fields when mutation must be prevented — primary constructor parameters are mutable by default
Collection Expressions (C# 12+)
- Use collection expressions (
[1, 2, 3]) for initializing arrays, lists, spans, and immutable collections — unified syntax
- Use the spread operator (
..) to combine collections: [..existing, newItem]
Dependency Injection
- Register services in
Program.cs — use the built-in DI container
- Use constructor injection — never resolve from the container manually
- Register scoped services for per-request lifetimes
- Use
IOptions<T> for typed configuration binding
Testing
- Use xUnit with
[Theory] and [InlineData] for parameterized tests
- Use
[Fact] for single-case tests, [Theory] for data-driven tests
- Mock dependencies with NSubstitute or Moq — never mock the class under test
- Name tests:
MethodName_Condition_ExpectedResult
Database & Security
- Use Entity Framework with LINQ queries — avoid raw SQL
- Load secrets via
IConfiguration and the Secret Manager in development
- Use Azure Key Vault or equivalent in production — never hardcode secrets
- Validate input with data annotations or FluentValidation at API boundaries
Native AOT
- Use Native AOT for microservices and serverless functions — reduces startup time and eliminates JIT overhead
- Avoid reflection-heavy patterns in AOT targets — use source generators instead
- Use
[JsonSerializable] source generator for System.Text.Json in AOT contexts — reflection-based serialization does not work
Source Generators
- Use source generators instead of runtime reflection for serialization, logging, and DI — required for AOT compatibility
- Use
[LoggerMessage] source generator for high-performance structured logging — avoids boxing and string interpolation overhead
- Use
Span<T> and ReadOnlySpan<T> for slicing data without heap allocations — significantly faster for string processing
- Use
Memory<T> when the buffer must be stored on the heap or passed across async boundaries — Span<T> is stack-only
- Prefer
ArrayPool<T>.Shared for reusable buffers in hot paths — reduces allocation churn
Observability
- Use OpenTelemetry for distributed tracing, metrics, and structured logging — CNCF standard with first-class .NET support
- Configure
ActivitySource for custom trace spans and Meter for custom metrics
- Use .NET Aspire for cloud-native distributed applications — provides service discovery, health checks, and telemetry out of the box
Error Handling
- Use
ILogger<T> with structured logging — include correlation IDs
- Define custom exception types for domain-specific errors
- Use global exception handling middleware — no scattered try/catch; centralized handling ensures consistent error responses
- Return
ProblemDetails responses for API errors (RFC 9457)