.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.cs2. Dependency Injection Patterns
csharp
// Service registration by lifetimepublic static class ServiceCollectionExtensions{ public static IServiceCollection AddApplicationServices( this IServiceCollection services, IConfiguration configuration) { // Scoped: One instance per HTTP request services.AddScoped<IProductService, ProductService>(); services.AddScoped<IOrderService, OrderService>();
// Singleton: One instance for app lifetime services.AddSingleton<ICacheService, RedisCacheService>(); services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(configuration["Redis:Connection"]!));
// Transient: New instance every time services.AddTransient<IValidator<CreateOrderRequest>, CreateOrderValidator>();
// Options pattern for configuration services.Configure<CatalogOptions>(configuration.GetSection("Catalog")); services.Configure<RedisOptions>(configuration.GetSection("Redis"));
// Factory pattern for conditional creation services.AddScoped<IPriceCalculator>(sp => { var options = sp.GetRequiredService<IOptions<PricingOptions>>().Value; return options.UseNewEngine ? sp.GetRequiredService<NewPriceCalculator>() : sp.GetRequiredService<LegacyPriceCalculator>(); });
// Keyed services (.NET 8+) services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("stripe"); services.AddKeyedScoped<IPaymentProcessor, PayPalProcessor>("paypal");
return services; }}
// Usage with keyed servicespublic class CheckoutService{ public CheckoutService( [FromKeyedServices("stripe")] IPaymentProcessor stripeProcessor) { _processor = stripeProcessor; }}3. Async/Await Patterns
csharp
// ✅ CORRECT: Async all the way downpublic async Task<Product> GetProductAsync(string id, CancellationToken ct = default){ return await _repository.GetByIdAsync(id, ct);}
// ✅ CORRECT: Parallel execution with WhenAllpublic 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 librariespublic async Task<T> LibraryMethodAsync<T>(CancellationToken ct = default){ var result = await _httpClient.GetAsync(url, ct).ConfigureAwait(false); return await result.Content.ReadFromJsonAsync<T>(ct).ConfigureAwait(false);}
// ✅ CORRECT: ValueTask for hot paths with cachingpublic ValueTask<Product?> GetCachedProductAsync(string id){ if (_cache.TryGetValue(id, out Product? product)) return ValueTask.FromResult(product);
return new ValueTask<Product?>(GetFromDatabaseAsync(id));}
// ❌ WRONG: Blocking on async (deadlock risk)var result = GetProductAsync(id).Result; // NEVER do thisvar 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 codeawait Task.Run(async () => await GetDataAsync()); // Wastes thread4. Configuration with IOptions
csharp
// Configuration classespublic 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 }}
// Registrationservices.Configure<CatalogOptions>(configuration.GetSection(CatalogOptions.SectionName));services.Configure<RedisOptions>(configuration.GetSection(RedisOptions.SectionName));
// Usage with IOptions (singleton, read once at startup)public class CatalogService{ private readonly CatalogOptions _options;
public CatalogService(IOptions<CatalogOptions> options) { _options = options.Value; }}
// Usage with IOptionsSnapshot (scoped, re-reads on each request)public class DynamicService{ private readonly CatalogOptions _options;
public DynamicService(IOptionsSnapshot<CatalogOptions> options) { _options = options.Value; // Fresh value per request }}
// Usage with IOptionsMonitor (singleton, notified on changes)public class MonitoredService{ private CatalogOptions _options;
public MonitoredService(IOptionsMonitor<CatalogOptions> monitor) { _options = monitor.CurrentValue; monitor.OnChange(newOptions => _options = newOptions); }}5. Result Pattern (Avoiding Exceptions for Flow Control)
csharp
// Generic Result typepublic class Result<T>{ 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<T> Success(T value) => new(true, value, null, null); public static Result<T> Failure(string error, string? code = null) => new(false, default, error, code);
public Result<TNew> Map<TNew>(Func<T, TNew> mapper) => IsSuccess ? Result<TNew>.Success(mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
public async Task<Result<TNew>> MapAsync<TNew>(Func<T, Task<TNew>> mapper) => IsSuccess ? Result<TNew>.Success(await mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);}
// Usage in servicepublic async Task<Result<Order>> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct){ // Validation var validation = await _validator.ValidateAsync(request, ct); if (!validation.IsValid) return Result<Order>.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<Order>.Failure( $"Insufficient stock: {stock.Available} available, {request.Quantity} requested", "INSUFFICIENT_STOCK");
// Create order var order = await _repository.CreateAsync(request.ToEntity(), ct);
return Result<Order>.Success(order);}
// Usage in controller/endpointapp.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 configurationpublic class AppDbContext : DbContext{ public DbSet<Product> Products => Set<Product>(); public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { // Apply all configurations from assembly modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
// Global query filters modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted); }}
// Entity configurationpublic class ProductConfiguration : IEntityTypeConfiguration<Product>{ public void Configure(EntityTypeBuilder<Product> 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 Corepublic class ProductRepository : IProductRepository{ private readonly AppDbContext _context;
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default) { return await _context.Products .AsNoTracking() .FirstOrDefaultAsync(p => p.Id == id, ct); }
public async Task<IReadOnlyList<Product>> 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<Product?> 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<Product>( new CommandDefinition(sql, new { Id = id }, cancellationToken: ct)); }
public async Task<IReadOnlyList<Product>> 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<Product>( new CommandDefinition(sql.ToString(), parameters, cancellationToken: ct));
return results.ToList(); }
// Multi-mapping for related data public async Task<Order?> 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<int, Order>();
await _connection.QueryAsync<Order, OrderItem, Product, Order>( 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<OrderItem>(); 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<CachedProductService> _logger;
private static readonly TimeSpan MemoryCacheDuration = TimeSpan.FromMinutes(1); private static readonly TimeSpan DistributedCacheDuration = TimeSpan.FromMinutes(15);
public async Task<Product?> 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<Product>(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 patternpublic class StaleWhileRevalidateCache<T>{ private readonly IDistributedCache _cache; private readonly TimeSpan _freshDuration; private readonly TimeSpan _staleDuration;
public async Task<T?> GetOrCreateAsync( string key, Func<CancellationToken, Task<T>> factory, CancellationToken ct = default) { var cached = await _cache.GetStringAsync(key, ct);
if (cached != null) { var entry = JsonSerializer.Deserialize<CacheEntry<T>>(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>(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<IOrderRepository> _mockRepository; private readonly Mock<IStockService> _mockStockService; private readonly Mock<IValidator<CreateOrderRequest>> _mockValidator; private readonly OrderService _sut; // System Under Test
public OrderServiceTests() { _mockRepository = new Mock<IOrderRepository>(); _mockStockService = new Mock<IStockService>(); _mockValidator = new Mock<IValidator<CreateOrderRequest>>();
// Default: validation passes _mockValidator .Setup(v => v.ValidateAsync(It.IsAny<CreateOrderRequest>(), It.IsAny<CancellationToken>())) .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<CancellationToken>())) .ReturnsAsync(new StockResult { IsAvailable = true, Available = 10 });
_mockRepository .Setup(r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>())) .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<Order>(o => o.CustomerOrderCode == "ORD-2024-001"), It.IsAny<CancellationToken>()), 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<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>())) .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<Order>(), It.IsAny<CancellationToken>()), 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<CancellationToken>())) .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<WebApplicationFactory<Program>>{ private readonly WebApplicationFactory<Program> _factory; private readonly HttpClient _client;
public ProductsApiTests(WebApplicationFactory<Program> factory) { _factory = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Replace real database with in-memory services.RemoveAll<DbContextOptions<AppDbContext>>(); services.AddDbContext<AppDbContext>(options => options.UseInMemoryDatabase("TestDb"));
// Replace Redis with memory cache services.RemoveAll<IDistributedCache>(); services.AddDistributedMemoryCache(); }); });
_client = _factory.CreateClient(); }
[Fact] public async Task GetProduct_WithValidId_ReturnsProduct() { // Arrange using var scope = _factory.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<Product>(); 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
- Use async/await all the way through the call stack
- Inject dependencies through constructor injection
- Use IOptions<T> for typed configuration
- Return Result types instead of throwing exceptions for business logic
- Use CancellationToken in all async methods
- Prefer Dapper for read-heavy, performance-critical queries
- Use EF Core for complex domain models with change tracking
- Cache aggressively with proper invalidation strategies
- Write unit tests for business logic, integration tests for APIs
- Use record types for DTOs and immutable data
DON'T
- Don't block on async with
.Resultor.Wait() - Don't use async void except for event handlers
- Don't catch generic Exception without re-throwing or logging
- Don't hardcode configuration values
- Don't expose EF entities directly in APIs (use DTOs)
- Don't forget
AsNoTracking()for read-only queries - Don't ignore CancellationToken parameters
- Don't create
new HttpClient()manually (use IHttpClientFactory) - Don't mix sync and async code unnecessarily
- 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


