--- name: dotnet-backend-patterns description: Master C#/.NET backend development patterns for building robust APIs, MCP servers, and enterprise applications. Covers async/await, dependency injection, Entity Framework Core, Dapper, configuration, caching, and testing with xUnit. Use when developing .NET backends, reviewing C# code, or designing API architectures. --- # .NET Backend Development Patterns Master C#/.NET patterns for building production-grade APIs, MCP servers, and enterprise backends with modern best practices (2024/2025). ## When to Use This Skill - Developing new .NET Web APIs or MCP servers - Reviewing C# code for quality and performance - Designing service architectures with dependency injection - Implementing caching strategies with Redis - Writing unit and integration tests - Optimizing database access with EF Core or Dapper - Configuring applications with IOptions pattern - Handling errors and implementing resilience patterns ## Core Concepts ### 1. Project Structure (Clean Architecture) ``` src/ ├── Domain/ # Core business logic (no dependencies) │ ├── Entities/ │ ├── Interfaces/ │ ├── Exceptions/ │ └── ValueObjects/ ├── Application/ # Use cases, DTOs, validation │ ├── Services/ │ ├── DTOs/ │ ├── Validators/ │ └── Interfaces/ ├── Infrastructure/ # External implementations │ ├── Data/ # EF Core, Dapper repositories │ ├── Caching/ # Redis, Memory cache │ ├── External/ # HTTP clients, third-party APIs │ └── DependencyInjection/ # Service registration └── Api/ # Entry point ├── Controllers/ # Or MinimalAPI endpoints ├── Middleware/ ├── Filters/ └── Program.cs ``` ### 2. Dependency Injection Patterns ```csharp // Service registration by lifetime public static class ServiceCollectionExtensions { public static IServiceCollection AddApplicationServices( this IServiceCollection services, IConfiguration configuration) { // Scoped: One instance per HTTP request services.AddScoped(); services.AddScoped(); // Singleton: One instance for app lifetime services.AddSingleton(); services.AddSingleton(_ => ConnectionMultiplexer.Connect(configuration["Redis:Connection"]!)); // Transient: New instance every time services.AddTransient, CreateOrderValidator>(); // Options pattern for configuration services.Configure(configuration.GetSection("Catalog")); services.Configure(configuration.GetSection("Redis")); // Factory pattern for conditional creation services.AddScoped(sp => { var options = sp.GetRequiredService>().Value; return options.UseNewEngine ? sp.GetRequiredService() : sp.GetRequiredService(); }); // Keyed services (.NET 8+) services.AddKeyedScoped("stripe"); services.AddKeyedScoped("paypal"); return services; } } // Usage with keyed services public class CheckoutService { public CheckoutService( [FromKeyedServices("stripe")] IPaymentProcessor stripeProcessor) { _processor = stripeProcessor; } } ``` ### 3. Async/Await Patterns ```csharp // ✅ CORRECT: Async all the way down public async Task GetProductAsync(string id, CancellationToken ct = default) { return await _repository.GetByIdAsync(id, ct); } // ✅ CORRECT: Parallel execution with WhenAll public async Task<(Stock, Price)> GetStockAndPriceAsync( string productId, CancellationToken ct = default) { var stockTask = _stockService.GetAsync(productId, ct); var priceTask = _priceService.GetAsync(productId, ct); await Task.WhenAll(stockTask, priceTask); return (await stockTask, await priceTask); } // ✅ CORRECT: ConfigureAwait in libraries public async Task LibraryMethodAsync(CancellationToken ct = default) { var result = await _httpClient.GetAsync(url, ct).ConfigureAwait(false); return await result.Content.ReadFromJsonAsync(ct).ConfigureAwait(false); } // ✅ CORRECT: ValueTask for hot paths with caching public ValueTask GetCachedProductAsync(string id) { if (_cache.TryGetValue(id, out Product? product)) return ValueTask.FromResult(product); return new ValueTask(GetFromDatabaseAsync(id)); } // ❌ WRONG: Blocking on async (deadlock risk) var result = GetProductAsync(id).Result; // NEVER do this var result2 = GetProductAsync(id).GetAwaiter().GetResult(); // Also bad // ❌ WRONG: async void (except event handlers) public async void ProcessOrder() { } // Exceptions are lost // ❌ WRONG: Unnecessary Task.Run for already async code await Task.Run(async () => await GetDataAsync()); // Wastes thread ``` ### 4. Configuration with IOptions ```csharp // Configuration classes public class CatalogOptions { public const string SectionName = "Catalog"; public int DefaultPageSize { get; set; } = 50; public int MaxPageSize { get; set; } = 200; public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(15); public bool EnableEnrichment { get; set; } = true; } public class RedisOptions { public const string SectionName = "Redis"; public string Connection { get; set; } = "localhost:6379"; public string KeyPrefix { get; set; } = "mcp:"; public int Database { get; set; } = 0; } // appsettings.json { "Catalog": { "DefaultPageSize": 50, "MaxPageSize": 200, "CacheDuration": "00:15:00", "EnableEnrichment": true }, "Redis": { "Connection": "localhost:6379", "KeyPrefix": "mcp:", "Database": 0 } } // Registration services.Configure(configuration.GetSection(CatalogOptions.SectionName)); services.Configure(configuration.GetSection(RedisOptions.SectionName)); // Usage with IOptions (singleton, read once at startup) public class CatalogService { private readonly CatalogOptions _options; public CatalogService(IOptions options) { _options = options.Value; } } // Usage with IOptionsSnapshot (scoped, re-reads on each request) public class DynamicService { private readonly CatalogOptions _options; public DynamicService(IOptionsSnapshot options) { _options = options.Value; // Fresh value per request } } // Usage with IOptionsMonitor (singleton, notified on changes) public class MonitoredService { private CatalogOptions _options; public MonitoredService(IOptionsMonitor monitor) { _options = monitor.CurrentValue; monitor.OnChange(newOptions => _options = newOptions); } } ``` ### 5. Result Pattern (Avoiding Exceptions for Flow Control) ```csharp // Generic Result type public class Result { public bool IsSuccess { get; } public T? Value { get; } public string? Error { get; } public string? ErrorCode { get; } private Result(bool isSuccess, T? value, string? error, string? errorCode) { IsSuccess = isSuccess; Value = value; Error = error; ErrorCode = errorCode; } public static Result Success(T value) => new(true, value, null, null); public static Result Failure(string error, string? code = null) => new(false, default, error, code); public Result Map(Func mapper) => IsSuccess ? Result.Success(mapper(Value!)) : Result.Failure(Error!, ErrorCode); public async Task> MapAsync(Func> mapper) => IsSuccess ? Result.Success(await mapper(Value!)) : Result.Failure(Error!, ErrorCode); } // Usage in service public async Task> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct) { // Validation var validation = await _validator.ValidateAsync(request, ct); if (!validation.IsValid) return Result.Failure( validation.Errors.First().ErrorMessage, "VALIDATION_ERROR"); // Business rule check var stock = await _stockService.CheckAsync(request.ProductId, request.Quantity, ct); if (!stock.IsAvailable) return Result.Failure( $"Insufficient stock: {stock.Available} available, {request.Quantity} requested", "INSUFFICIENT_STOCK"); // Create order var order = await _repository.CreateAsync(request.ToEntity(), ct); return Result.Success(order); } // Usage in controller/endpoint app.MapPost("/orders", async ( CreateOrderRequest request, IOrderService orderService, CancellationToken ct) => { var result = await orderService.CreateOrderAsync(request, ct); return result.IsSuccess ? Results.Created($"/orders/{result.Value!.Id}", result.Value) : Results.BadRequest(new { error = result.Error, code = result.ErrorCode }); }); ``` ## Data Access Patterns ### Entity Framework Core ```csharp // DbContext configuration public class AppDbContext : DbContext { public DbSet Products => Set(); public DbSet Orders => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { // Apply all configurations from assembly modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); // Global query filters modelBuilder.Entity().HasQueryFilter(p => !p.IsDeleted); } } // Entity configuration public class ProductConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.ToTable("Products"); builder.HasKey(p => p.Id); builder.Property(p => p.Id).HasMaxLength(40); builder.Property(p => p.Name).HasMaxLength(200).IsRequired(); builder.Property(p => p.Price).HasPrecision(18, 2); builder.HasIndex(p => p.Sku).IsUnique(); builder.HasIndex(p => new { p.CategoryId, p.Name }); builder.HasMany(p => p.OrderItems) .WithOne(oi => oi.Product) .HasForeignKey(oi => oi.ProductId); } } // Repository with EF Core public class ProductRepository : IProductRepository { private readonly AppDbContext _context; public async Task GetByIdAsync(string id, CancellationToken ct = default) { return await _context.Products .AsNoTracking() .FirstOrDefaultAsync(p => p.Id == id, ct); } public async Task> SearchAsync( ProductSearchCriteria criteria, CancellationToken ct = default) { var query = _context.Products.AsNoTracking(); if (!string.IsNullOrWhiteSpace(criteria.SearchTerm)) query = query.Where(p => EF.Functions.Like(p.Name, $"%{criteria.SearchTerm}%")); if (criteria.CategoryId.HasValue) query = query.Where(p => p.CategoryId == criteria.CategoryId); if (criteria.MinPrice.HasValue) query = query.Where(p => p.Price >= criteria.MinPrice); if (criteria.MaxPrice.HasValue) query = query.Where(p => p.Price <= criteria.MaxPrice); return await query .OrderBy(p => p.Name) .Skip((criteria.Page - 1) * criteria.PageSize) .Take(criteria.PageSize) .ToListAsync(ct); } } ``` ### Dapper for Performance ```csharp public class DapperProductRepository : IProductRepository { private readonly IDbConnection _connection; public async Task GetByIdAsync(string id, CancellationToken ct = default) { const string sql = """ SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt FROM Products WHERE Id = @Id AND IsDeleted = 0 """; return await _connection.QueryFirstOrDefaultAsync( new CommandDefinition(sql, new { Id = id }, cancellationToken: ct)); } public async Task> SearchAsync( ProductSearchCriteria criteria, CancellationToken ct = default) { var sql = new StringBuilder(""" SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt FROM Products WHERE IsDeleted = 0 """); var parameters = new DynamicParameters(); if (!string.IsNullOrWhiteSpace(criteria.SearchTerm)) { sql.Append(" AND Name LIKE @SearchTerm"); parameters.Add("SearchTerm", $"%{criteria.SearchTerm}%"); } if (criteria.CategoryId.HasValue) { sql.Append(" AND CategoryId = @CategoryId"); parameters.Add("CategoryId", criteria.CategoryId); } if (criteria.MinPrice.HasValue) { sql.Append(" AND Price >= @MinPrice"); parameters.Add("MinPrice", criteria.MinPrice); } if (criteria.MaxPrice.HasValue) { sql.Append(" AND Price <= @MaxPrice"); parameters.Add("MaxPrice", criteria.MaxPrice); } sql.Append(" ORDER BY Name OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY"); parameters.Add("Offset", (criteria.Page - 1) * criteria.PageSize); parameters.Add("PageSize", criteria.PageSize); var results = await _connection.QueryAsync( new CommandDefinition(sql.ToString(), parameters, cancellationToken: ct)); return results.ToList(); } // Multi-mapping for related data public async Task GetOrderWithItemsAsync(int orderId, CancellationToken ct = default) { const string sql = """ SELECT o.*, oi.*, p.* FROM Orders o LEFT JOIN OrderItems oi ON o.Id = oi.OrderId LEFT JOIN Products p ON oi.ProductId = p.Id WHERE o.Id = @OrderId """; var orderDictionary = new Dictionary(); await _connection.QueryAsync( new CommandDefinition(sql, new { OrderId = orderId }, cancellationToken: ct), (order, item, product) => { if (!orderDictionary.TryGetValue(order.Id, out var existingOrder)) { existingOrder = order; existingOrder.Items = new List(); orderDictionary.Add(order.Id, existingOrder); } if (item != null) { item.Product = product; existingOrder.Items.Add(item); } return existingOrder; }, splitOn: "Id,Id"); return orderDictionary.Values.FirstOrDefault(); } } ``` ## Caching Patterns ### Multi-Level Cache with Redis ```csharp public class CachedProductService : IProductService { private readonly IProductRepository _repository; private readonly IMemoryCache _memoryCache; private readonly IDistributedCache _distributedCache; private readonly ILogger _logger; private static readonly TimeSpan MemoryCacheDuration = TimeSpan.FromMinutes(1); private static readonly TimeSpan DistributedCacheDuration = TimeSpan.FromMinutes(15); public async Task GetByIdAsync(string id, CancellationToken ct = default) { var cacheKey = $"product:{id}"; // L1: Memory cache (in-process, fastest) if (_memoryCache.TryGetValue(cacheKey, out Product? cached)) { _logger.LogDebug("L1 cache hit for {CacheKey}", cacheKey); return cached; } // L2: Distributed cache (Redis) var distributed = await _distributedCache.GetStringAsync(cacheKey, ct); if (distributed != null) { _logger.LogDebug("L2 cache hit for {CacheKey}", cacheKey); var product = JsonSerializer.Deserialize(distributed); // Populate L1 _memoryCache.Set(cacheKey, product, MemoryCacheDuration); return product; } // L3: Database _logger.LogDebug("Cache miss for {CacheKey}, fetching from database", cacheKey); var fromDb = await _repository.GetByIdAsync(id, ct); if (fromDb != null) { var serialized = JsonSerializer.Serialize(fromDb); // Populate both caches await _distributedCache.SetStringAsync( cacheKey, serialized, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = DistributedCacheDuration }, ct); _memoryCache.Set(cacheKey, fromDb, MemoryCacheDuration); } return fromDb; } public async Task InvalidateAsync(string id, CancellationToken ct = default) { var cacheKey = $"product:{id}"; _memoryCache.Remove(cacheKey); await _distributedCache.RemoveAsync(cacheKey, ct); _logger.LogInformation("Invalidated cache for {CacheKey}", cacheKey); } } // Stale-while-revalidate pattern public class StaleWhileRevalidateCache { private readonly IDistributedCache _cache; private readonly TimeSpan _freshDuration; private readonly TimeSpan _staleDuration; public async Task GetOrCreateAsync( string key, Func> factory, CancellationToken ct = default) { var cached = await _cache.GetStringAsync(key, ct); if (cached != null) { var entry = JsonSerializer.Deserialize>(cached)!; if (entry.IsStale && !entry.IsExpired) { // Return stale data immediately, refresh in background _ = Task.Run(async () => { var fresh = await factory(CancellationToken.None); await SetAsync(key, fresh, CancellationToken.None); }); } if (!entry.IsExpired) return entry.Value; } // Cache miss or expired var value = await factory(ct); await SetAsync(key, value, ct); return value; } private record CacheEntry(TValue Value, DateTime CreatedAt) { public bool IsStale => DateTime.UtcNow - CreatedAt > _freshDuration; public bool IsExpired => DateTime.UtcNow - CreatedAt > _staleDuration; } } ``` ## Testing Patterns ### Unit Tests with xUnit and Moq ```csharp public class OrderServiceTests { private readonly Mock _mockRepository; private readonly Mock _mockStockService; private readonly Mock> _mockValidator; private readonly OrderService _sut; // System Under Test public OrderServiceTests() { _mockRepository = new Mock(); _mockStockService = new Mock(); _mockValidator = new Mock>(); // Default: validation passes _mockValidator .Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new ValidationResult()); _sut = new OrderService( _mockRepository.Object, _mockStockService.Object, _mockValidator.Object); } [Fact] public async Task CreateOrderAsync_WithValidRequest_ReturnsSuccess() { // Arrange var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = 5, CustomerOrderCode = "ORD-2024-001" }; _mockStockService .Setup(s => s.CheckAsync("PROD-001", 5, It.IsAny())) .ReturnsAsync(new StockResult { IsAvailable = true, Available = 10 }); _mockRepository .Setup(r => r.CreateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new Order { Id = 1, CustomerOrderCode = "ORD-2024-001" }); // Act var result = await _sut.CreateOrderAsync(request); // Assert Assert.True(result.IsSuccess); Assert.NotNull(result.Value); Assert.Equal(1, result.Value.Id); _mockRepository.Verify( r => r.CreateAsync(It.Is(o => o.CustomerOrderCode == "ORD-2024-001"), It.IsAny()), Times.Once); } [Fact] public async Task CreateOrderAsync_WithInsufficientStock_ReturnsFailure() { // Arrange var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = 100 }; _mockStockService .Setup(s => s.CheckAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new StockResult { IsAvailable = false, Available = 5 }); // Act var result = await _sut.CreateOrderAsync(request); // Assert Assert.False(result.IsSuccess); Assert.Equal("INSUFFICIENT_STOCK", result.ErrorCode); Assert.Contains("5 available", result.Error); _mockRepository.Verify( r => r.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); } [Theory] [InlineData(0)] [InlineData(-1)] [InlineData(-100)] public async Task CreateOrderAsync_WithInvalidQuantity_ReturnsValidationError(int quantity) { // Arrange var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = quantity }; _mockValidator .Setup(v => v.ValidateAsync(request, It.IsAny())) .ReturnsAsync(new ValidationResult(new[] { new ValidationFailure("Quantity", "Quantity must be greater than 0") })); // Act var result = await _sut.CreateOrderAsync(request); // Assert Assert.False(result.IsSuccess); Assert.Equal("VALIDATION_ERROR", result.ErrorCode); } } ``` ### Integration Tests with WebApplicationFactory ```csharp public class ProductsApiTests : IClassFixture> { private readonly WebApplicationFactory _factory; private readonly HttpClient _client; public ProductsApiTests(WebApplicationFactory factory) { _factory = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Replace real database with in-memory services.RemoveAll>(); services.AddDbContext(options => options.UseInMemoryDatabase("TestDb")); // Replace Redis with memory cache services.RemoveAll(); services.AddDistributedMemoryCache(); }); }); _client = _factory.CreateClient(); } [Fact] public async Task GetProduct_WithValidId_ReturnsProduct() { // Arrange using var scope = _factory.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); context.Products.Add(new Product { Id = "TEST-001", Name = "Test Product", Price = 99.99m }); await context.SaveChangesAsync(); // Act var response = await _client.GetAsync("/api/products/TEST-001"); // Assert response.EnsureSuccessStatusCode(); var product = await response.Content.ReadFromJsonAsync(); Assert.Equal("Test Product", product!.Name); } [Fact] public async Task GetProduct_WithInvalidId_Returns404() { // Act var response = await _client.GetAsync("/api/products/NONEXISTENT"); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } } ``` ## Best Practices ### DO 1. **Use async/await** all the way through the call stack 2. **Inject dependencies** through constructor injection 3. **Use IOptions** for typed configuration 4. **Return Result types** instead of throwing exceptions for business logic 5. **Use CancellationToken** in all async methods 6. **Prefer Dapper** for read-heavy, performance-critical queries 7. **Use EF Core** for complex domain models with change tracking 8. **Cache aggressively** with proper invalidation strategies 9. **Write unit tests** for business logic, integration tests for APIs 10. **Use record types** for DTOs and immutable data ### DON'T 1. **Don't block on async** with `.Result` or `.Wait()` 2. **Don't use async void** except for event handlers 3. **Don't catch generic Exception** without re-throwing or logging 4. **Don't hardcode** configuration values 5. **Don't expose EF entities** directly in APIs (use DTOs) 6. **Don't forget** `AsNoTracking()` for read-only queries 7. **Don't ignore** CancellationToken parameters 8. **Don't create** `new HttpClient()` manually (use IHttpClientFactory) 9. **Don't mix** sync and async code unnecessarily 10. **Don't skip** validation at API boundaries ## Common Pitfalls - **N+1 Queries**: Use `.Include()` or explicit joins - **Memory Leaks**: Dispose IDisposable resources, use `using` - **Deadlocks**: Don't mix sync and async, use ConfigureAwait(false) in libraries - **Over-fetching**: Select only needed columns, use projections - **Missing Indexes**: Check query plans, add indexes for common filters - **Timeout Issues**: Configure appropriate timeouts for HTTP clients - **Cache Stampede**: Use distributed locks for cache population ## Resources - **assets/service-template.cs**: Complete service implementation template - **assets/repository-template.cs**: Repository pattern implementation - **references/ef-core-best-practices.md**: EF Core optimization guide - **references/dapper-patterns.md**: Advanced Dapper usage patterns