C# – Best Practices
This page covers the practices that distinguish production-grade C# from code that merely compiles. These standards apply to all .cs files at Cygnus Dynamics across all layers — controllers, services, repositories, and domain models.
XML Documentation Comments
All public and protected members must have XML documentation comments. These are processed by Visual Studio IntelliSense, Rider, and documentation generators.
Class Documentation
/// <summary>
/// Manages the full lifecycle of customer orders from creation through delivery.
/// </summary>
/// <remarks>
/// This service is the single authoritative entry point for all order operations.
/// It coordinates with <see cref="IPaymentService"/> and <see cref="IInventoryService"/>
/// to ensure transactional consistency across the checkout flow.
/// </remarks>
public class OrderService
{
Method Documentation
/// <summary>
/// Retrieves an order by its unique identifier.
/// </summary>
/// <param name="orderId">The unique identifier of the order. Must be greater than zero.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The order matching the given identifier.</returns>
/// <exception cref="OrderNotFoundException">
/// Thrown when no order with the specified <paramref name="orderId"/> exists.
/// </exception>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="orderId"/> is less than or equal to zero.
/// </exception>
public async Task<Order> GetOrderAsync(int orderId, CancellationToken cancellationToken = default)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(orderId);
return await _repository.FindByIdAsync(orderId, cancellationToken)
?? throw new OrderNotFoundException(orderId);
}
Property Documentation
/// <summary>Gets the total cost of all items in the order, before tax.</summary>
public decimal Subtotal => Items.Sum(i => i.UnitPrice * i.Quantity);
/// <summary>Gets or sets the ISO 4217 currency code for this order.</summary>
/// <example>"USD", "EUR", "GBP"</example>
public required string Currency { get; init; }
Update documentation when you change behaviour
An XML comment that contradicts the method's actual behaviour misleads every caller. When you change a method, update the doc comment in the same commit.
Exception Handling
Use Specific Exception Types
// ✅ Specific catch — handles the expected failure precisely
try
{
var order = await _repository.FindByIdAsync(orderId, ct);
if (order is null)
throw new OrderNotFoundException(orderId);
return order;
}
catch (SqlException ex) when (ex.Number == 1205) // deadlock
{
_logger.LogWarning("Deadlock on order {OrderId} — retrying", orderId);
throw new TransientDatabaseException("Deadlock detected", ex);
}
// ❌ Too broad — hides bugs
try { ... }
catch (Exception ex) // catches NullReferenceException, OutOfMemoryException, everything
{
_logger.LogError(ex, "Something failed");
return null;
}
Exception Filters with when
// ✅ Only catches specific HTTP status ranges
try
{
await _httpClient.PostAsync(endpoint, content, ct);
}
catch (HttpRequestException ex) when ((int?)ex.StatusCode is >= 500 and < 600)
{
throw new ExternalServiceException("Upstream server error", ex);
}
catch (HttpRequestException ex) when ((int?)ex.StatusCode == 429)
{
throw new RateLimitExceededException("Too many requests to external service", ex);
}
Custom Exception Classes
Define a typed exception for every distinct domain error. Include the failing identifier as a property:
// ✅ Typed exception with identifier property
public sealed class OrderNotFoundException : Exception
{
public int OrderId { get; }
public OrderNotFoundException(int orderId)
: base($"Order not found: orderId={orderId}")
{
OrderId = orderId;
}
public OrderNotFoundException(int orderId, Exception inner)
: base($"Order not found: orderId={orderId}", inner)
{
OrderId = orderId;
}
}
// ✅ Exception hierarchy — allows broad or specific catching
public abstract class OrderException : Exception
{
protected OrderException(string message) : base(message) { }
protected OrderException(string message, Exception inner) : base(message, inner) { }
}
public sealed class OrderNotFoundException : OrderException { ... }
public sealed class OrderAlreadyCancelledException : OrderException { ... }
public sealed class InvalidOrderStateException : OrderException { ... }
Global Exception Handling in ASP.NET Core
// Program.cs — register global handler using IExceptionHandler (.NET 8+)
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
=> _logger = logger;
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken ct)
{
var (statusCode, code) = exception switch
{
OrderNotFoundException => (StatusCodes.Status404NotFound, "ORDER_NOT_FOUND"),
ValidationException => (StatusCodes.Status400BadRequest, "VALIDATION_ERROR"),
UnauthorisedAccessException => (StatusCodes.Status403Forbidden, "FORBIDDEN"),
_ => (StatusCodes.Status500InternalServerError, "INTERNAL_ERROR"),
};
var logLevel = statusCode >= 500 ? LogLevel.Error : LogLevel.Warning;
_logger.Log(logLevel, exception, "Request failed: {Code}", code);
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(new
{
Code = code,
Message = statusCode < 500 ? exception.Message : "An unexpected error occurred.",
TraceId = Activity.Current?.Id ?? httpContext.TraceIdentifier,
}, ct);
return true;
}
}
// Registration in Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
Dependency Injection
Constructor Injection
// ✅ Constructor injection — all dependencies explicit, all fields readonly
public class OrderService
{
private readonly IOrderRepository _orderRepository;
private readonly IPaymentService _paymentService;
private readonly ILogger<OrderService> _logger;
public OrderService(
IOrderRepository orderRepository,
IPaymentService paymentService,
ILogger<OrderService> logger)
{
ArgumentNullException.ThrowIfNull(orderRepository);
ArgumentNullException.ThrowIfNull(paymentService);
ArgumentNullException.ThrowIfNull(logger);
_orderRepository = orderRepository;
_paymentService = paymentService;
_logger = logger;
}
}
// ✅ Primary constructor (C# 12) — more concise
public class OrderService(
IOrderRepository orderRepository,
IPaymentService paymentService,
ILogger<OrderService> logger)
{
private readonly IOrderRepository _orderRepository = orderRepository
?? throw new ArgumentNullException(nameof(orderRepository));
private readonly IPaymentService _paymentService = paymentService
?? throw new ArgumentNullException(nameof(paymentService));
private readonly ILogger<OrderService> _logger = logger
?? throw new ArgumentNullException(nameof(logger));
}
Service Lifetimes
| Lifetime | Use When | Example |
|---|---|---|
Scoped |
One instance per HTTP request | OrderService, DbContext |
Transient |
New instance every time requested | Lightweight stateless helpers |
Singleton |
Shared across the application lifetime | IMemoryCache, connection pools |
// Program.cs
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IPaymentService, StripePaymentService>();
builder.Services.AddSingleton<IEmailTemplateCache, EmailTemplateCache>();
Configuration with IOptions<T>
Use the Options pattern for configuration binding. Never inject IConfiguration directly into services — it bypasses validation and is difficult to test.
// ✅ Strongly-typed options class
public sealed class StripeOptions
{
public const string Section = "Stripe";
public required string SecretKey { get; init; }
public required string WebhookSecret { get; init; }
public int TimeoutSeconds { get; init; } = 30;
}
// ✅ Register and validate at startup
builder.Services
.AddOptions<StripeOptions>()
.BindConfiguration(StripeOptions.Section)
.ValidateDataAnnotations()
.ValidateOnStart(); // fails fast at startup if config is missing
// ✅ Inject via IOptions<T> — immutable, snapshot at registration time
public class StripePaymentService
{
private readonly StripeOptions _options;
public StripePaymentService(IOptions<StripeOptions> options)
=> _options = options.Value;
}
// ✅ Inject via IOptionsSnapshot<T> — re-read per request (for hot-reload scenarios)
public class FeatureFlagService
{
private readonly FeatureFlagOptions _options;
public FeatureFlagService(IOptionsSnapshot<FeatureFlagOptions> options)
=> _options = options.Value;
}
// ❌ Inject IConfiguration directly — no type safety, no validation
public class StripePaymentService
{
public StripePaymentService(IConfiguration config)
{
var key = config["Stripe:SecretKey"]; // nullable, no validation, not testable
}
}
Sealed Classes
Mark leaf classes sealed by default. Unsealed classes can be subclassed in ways you did not intend, creating unexpected coupling. Only remove sealed when inheritance is an intentional design decision.
// ✅ sealed by default — all concrete implementations
public sealed class OrderService : IOrderService { }
public sealed class StripePaymentGateway : IPaymentGateway { }
public sealed class SqlOrderRepository : IOrderRepository { }
// ✅ sealed on custom exceptions
public sealed class OrderNotFoundException : Exception { ... }
// ✅ Abstract/base classes are not sealed
public abstract class RepositoryBase<T> { }
public abstract class DomainEvent { }
// ❌ Unsealed without intent — anyone can subclass OrderService
public class OrderService : IOrderService { } // did you mean for this to be extensible?
sealed also enables JIT optimisations
The .NET JIT can devirtualise calls on sealed types, eliminating the overhead of virtual dispatch. Sealing leaf classes is both a correctness and a performance decision.
IDisposable and Resource Management
Implement IDisposable when your class owns unmanaged resources or IDisposable members. Use the standard dispose pattern:
// ✅ Full disposable pattern for classes that own both managed and unmanaged resources
public sealed class OrderExportService : IDisposable
{
private readonly HttpClient _httpClient;
private FileStream? _exportStream;
private bool _disposed;
public OrderExportService(IHttpClientFactory factory)
=> _httpClient = factory.CreateClient("exports");
public async Task ExportAsync(string path, CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_exportStream = File.Create(path);
await WriteOrdersAsync(_exportStream, ct);
}
public void Dispose()
{
if (_disposed) return;
_exportStream?.Dispose();
_httpClient.Dispose();
_disposed = true;
}
}
// ✅ Always prefer using declaration over manual Dispose
using var service = new OrderExportService(factory);
await service.ExportAsync("/exports/orders.csv", ct);
// Disposed automatically when scope exits
// ✅ IAsyncDisposable for async cleanup (database connections, streams)
public sealed class ReportGenerator : IAsyncDisposable
{
private readonly NpgsqlConnection _connection;
public ReportGenerator(string connectionString)
=> _connection = new NpgsqlConnection(connectionString);
public async ValueTask DisposeAsync()
=> await _connection.DisposeAsync();
}
await using var generator = new ReportGenerator(connectionString);
Entity Framework Core Patterns
DbContext Registration and Scoping
// ✅ Register DbContext as Scoped (default and correct for web apps)
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(
builder.Configuration.GetConnectionString("DefaultConnection"),
npgsql => npgsql
.MigrationsAssembly("CygnusDynamics.Infrastructure")
.EnableRetryOnFailure(3)));
// ✅ Use DbContextFactory for background services (Singleton scope)
builder.Services.AddDbContextFactory<AppDbContext>();
// In a background service
public class OrderArchiveService(IDbContextFactory<AppDbContext> factory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await using var context = await factory.CreateDbContextAsync(ct);
await ArchiveOldOrdersAsync(context, ct);
}
}
Querying Patterns
// ✅ AsNoTracking for read-only queries — significant performance gain
public async Task<IReadOnlyList<OrderDto>> GetOrderSummariesAsync(
int userId,
CancellationToken ct)
{
return await _context.Orders
.AsNoTracking() // no change tracking needed
.Where(o => o.UserId == userId && o.DeletedAt == null)
.OrderByDescending(o => o.CreatedAt)
.Select(o => new OrderDto(o.Id, o.Total, o.Status, o.CreatedAt))
.ToListAsync(ct);
}
// ✅ Use projection (Select) — never load entities you only need to read
// ❌ Loading full entity for a read-only operation wastes memory
var orders = await _context.Orders.Where(...).ToListAsync(ct); // loads all columns
var dtos = orders.Select(o => new OrderDto(...));
// ✅ Splitting large includes to avoid query multiplications
var order = await _context.Orders
.Where(o => o.Id == orderId)
.AsSplitQuery() // separate SQL queries for each Include, avoids cartesian explosion
.Include(o => o.Items)
.Include(o => o.Payments)
.Include(o => o.Shipments)
.FirstOrDefaultAsync(ct);
// ✅ Bulk operations without loading entities (EF Core 7+)
await _context.Orders
.Where(o => o.CreatedAt < cutoff && o.Status == OrderStatus.Cancelled)
.ExecuteDeleteAsync(ct);
await _context.Orders
.Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < timeout)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, OrderStatus.Expired), ct);
High-Performance Patterns — Span<T> and Memory<T>
Use Span<T> and Memory<T> for zero-allocation processing of arrays, strings, and buffers in hot code paths:
// ✅ Span<T> — zero-allocation slice of an array or string
public static bool TryParseOrderReference(
ReadOnlySpan<char> input,
out int orderId)
{
// Input: "ORD-000042"
// Parse without allocating a substring
var prefixLen = "ORD-".Length;
if (input.Length <= prefixLen || !input.StartsWith("ORD-"))
{
orderId = 0;
return false;
}
return int.TryParse(input[prefixLen..], out orderId);
}
// ✅ MemoryPool<T> for reusable buffers in high-throughput scenarios
public async Task<int> ReadChunksAsync(Stream stream, CancellationToken ct)
{
using var memoryOwner = MemoryPool<byte>.Shared.Rent(4096);
var buffer = memoryOwner.Memory;
int totalRead = 0;
while (true)
{
int bytesRead = await stream.ReadAsync(buffer, ct);
if (bytesRead == 0) break;
totalRead += bytesRead;
ProcessChunk(buffer.Span[..bytesRead]);
}
return totalRead;
}
Span\<T> is a hot-path tool — not for general use
Span<T> cannot be used as a field in a class, across await boundaries, or in async methods. Use it for CPU-bound processing in tight loops, parser hot paths, or format operations where allocation profiling has shown a measurable problem.
IAsyncEnumerable<T> for Streaming Results
Use IAsyncEnumerable<T> when you need to stream a large sequence without loading it all into memory first:
// ✅ IAsyncEnumerable — stream results without buffering all rows
public async IAsyncEnumerable<Order> StreamOrdersAsync(
int userId,
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var order in _context.Orders
.Where(o => o.UserId == userId)
.AsNoTracking()
.AsAsyncEnumerable()
.WithCancellation(ct))
{
yield return order;
}
}
// ✅ Consumer — processes each item as it arrives, no full materialisation
await foreach (var order in orderService.StreamOrdersAsync(userId, ct))
{
await exportService.WriteLineAsync(order, ct);
}
// ❌ Loading all records into memory — problematic for large datasets
var orders = await _context.Orders
.Where(o => o.UserId == userId)
.ToListAsync(ct); // 100k records → 100k objects in memory
SOLID Principles in Practice
Single Responsibility
Each class should have one reason to change:
// ❌ Controller doing too much — validation, business logic, persistence, email
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
if (request.Items.Count == 0) return BadRequest("No items");
decimal total = request.Items.Sum(i => i.Price * i.Quantity);
var order = new Order { UserId = request.UserId, Total = total };
_context.Orders.Add(order);
await _context.SaveChangesAsync();
await _emailClient.SendAsync(request.Email, "Order confirmed");
return Ok(order);
}
// ✅ Controller delegates to service — one responsibility each
[HttpPost]
public async Task<IActionResult> CreateOrder(
[FromBody] CreateOrderRequest request,
CancellationToken ct)
{
var order = await _orderService.CreateOrderAsync(request, ct);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order.ToResponse());
}
Dependency Inversion
Depend on abstractions (interface), not implementations:
// ✅ Depends on IOrderRepository — can be mocked in tests
public sealed class OrderService
{
private readonly IOrderRepository _repository;
public OrderService(IOrderRepository repository) => _repository = repository;
}
// ❌ Depends on concrete class — cannot be mocked, tightly coupled
public class OrderService
{
private readonly SqlOrderRepository _repository;
public OrderService() => _repository = new SqlOrderRepository(); // new = tight coupling
}
Logging
Use ILogger<T> with structured message templates — curly-brace placeholders produce indexed log properties that are searchable in log aggregation tools (Datadog, Seq, ELK).
// ✅ Structured logging — properties are indexed and searchable
_logger.LogInformation(
"Order {OrderId} created for user {UserId}. Total: {Total} {Currency}",
order.Id, order.UserId, order.Total, order.Currency);
_logger.LogWarning(
"Payment declined for order {OrderId}: decline code {DeclineCode}",
order.Id, result.DeclineCode);
_logger.LogError(
ex,
"Unhandled exception processing order {OrderId}",
orderId);
// ❌ String interpolation in log calls — defeats structured logging
_logger.LogInformation($"Order {order.Id} created for user {order.UserId}");
// ❌ Console.WriteLine — never in production code
Console.WriteLine("Order created");
Log Level Guidelines
| Level | Use When |
|---|---|
LogTrace |
Very detailed — only for active debugging sessions, never in production |
LogDebug |
Diagnostic detail — disabled by default in production |
LogInformation |
Key business events — order created, payment succeeded |
LogWarning |
Unexpected but handled — payment declined, retry attempt, validation failure |
LogError |
Failures requiring attention — exceptions, operation failures |
LogCritical |
System-level failures — database down, service unresponsive |
Comments
Implementation Comments
// ✅ Explains the business rule — not obvious from the code
// Stripe's idempotency key is limited to 255 chars. We prefix with the order ID
// and timestamp to guarantee uniqueness across retries.
var idempotencyKey = $"order_{orderId}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
// ✅ Explains a non-obvious performance decision
// HashSet for O(1) lookup — product catalogue has ~50k items and this runs per request.
var productIdSet = new HashSet<int>(catalogue.Select(p => p.Id));
// ❌ Narrates the code — zero information
// Increment the counter
count++;
// ❌ Commented-out code — delete it; git history preserves it
// var oldResult = LegacyProcessOrder(order);
Special Markers
// TODO(PROJ-1234): Replace with async notification when event bus is available.
await emailService.SendSyncAsync(user.Email, message, ct);
// FIXME(PROJ-5678): Refund calculation does not account for partial returns.
decimal refundTotal = order.Total;
// HACK: Stripe v3 SDK does not support idempotency on capture. Remove after upgrade.
await _stripeClient.CaptureWithoutIdempotencyAsync(chargeId, ct);
Markers must reference a ticket
A TODO or FIXME without a ticket is invisible to project planning. Always include the ticket ID.
Testing
Test File Naming
OrderService.cs → OrderServiceTests.cs
PaymentProcessor.cs → PaymentProcessorTests.cs
UserRepository.cs → UserRepositoryIntegrationTests.cs
OrderController.cs → OrderControllerTests.cs
Test Structure — Arrange / Act / Assert
using FluentAssertions;
using NSubstitute;
using Xunit;
public class OrderServiceTests
{
private readonly IOrderRepository _repository = Substitute.For<IOrderRepository>();
private readonly IPaymentService _paymentService = Substitute.For<IPaymentService>();
private readonly ILogger<OrderService> _logger = Substitute.For<ILogger<OrderService>>();
private readonly OrderService _sut;
public OrderServiceTests()
{
_sut = new OrderService(_repository, _paymentService, _logger);
}
[Fact]
public async Task GetOrderAsync_WhenOrderExists_ReturnsOrder()
{
// Arrange
var expected = new Order { Id = 1, UserId = 42, Total = 99.99m };
_repository.FindByIdAsync(1, Arg.Any<CancellationToken>())
.Returns(expected);
// Act
var result = await _sut.GetOrderAsync(1);
// Assert
result.Should().BeEquivalentTo(expected);
await _repository.Received(1).FindByIdAsync(1, Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetOrderAsync_WhenOrderNotFound_ThrowsOrderNotFoundException()
{
// Arrange
_repository.FindByIdAsync(999, Arg.Any<CancellationToken>())
.Returns((Order?)null);
// Act
var act = () => _sut.GetOrderAsync(999);
// Assert
await act.Should().ThrowAsync<OrderNotFoundException>()
.Where(ex => ex.OrderId == 999);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-100)]
public async Task GetOrderAsync_WhenIdIsInvalid_ThrowsArgumentOutOfRangeException(int id)
{
// Act
var act = () => _sut.GetOrderAsync(id);
// Assert
await act.Should().ThrowAsync<ArgumentOutOfRangeException>()
.WithParameterName("orderId");
}
}
Recommended test libraries:
| Library | Purpose |
|---|---|
| xUnit | Test framework |
| FluentAssertions | Readable assertion syntax |
| NSubstitute | Mocking |
| Bogus | Generating realistic test data |
| WebApplicationFactory | Integration tests for ASP.NET Core |
| Respawn | Database reset between integration tests |
Coverage expectations:
| Layer | Target |
|---|---|
| Service layer business logic | 90%+ unit test coverage |
| Domain models and value objects | 100% |
| Repository layer | Integration tests with a test database |
| API controllers | Integration tests covering all response codes |
Quick Reference
| Practice | Rule |
|---|---|
| XML docs | All public/protected members must be documented |
| Exceptions | Specific types; typed custom exceptions with identifier properties; global handler at API boundary |
| DI | Constructor injection; readonly fields; always depend on interfaces |
| Options pattern | IOptions<T> for config — never inject IConfiguration directly into services |
| sealed | All concrete leaf classes are sealed by default |
| IDisposable | Implement when owning disposable members; prefer using declarations |
| EF Core | AsNoTracking() for reads; Select for projection; ExecuteDeleteAsync/ExecuteUpdateAsync for bulk ops |
| Logging | ILogger<T> with structured message templates; never Console.WriteLine |
| async | All I/O is async; always pass CancellationToken; no .Result/.Wait() |
| Nulls | Enable <Nullable>enable</Nullable>; use ??, ?., ??=; validate at public boundaries |
| SOLID | One reason per class; depend on interfaces; inject all dependencies |
| Testing | xUnit + FluentAssertions + NSubstitute; Arrange/Act/Assert; 90%+ service coverage |
| Comments | Explain why, not what; TODO/FIXME must include ticket ID |
| Span\<T> | Zero-allocation slicing in hot paths — not for general use |
| IAsyncEnumerable\<T> | Streaming large sequences — avoids full materialisation |