<< All versions
Skill v1.0.1
currentAutomated scan100/100wshobson/agents/dotnet-backend-patterns
7 files
──Details
PublishedJune 20, 2026 at 08:07 PM
Content Hashsha256:0c5abad798ef47ba...
Git SHAcc37bfdd292c
Bump Typepatch
──Files
Files (1 file, 25.8 KB)
SKILL.md25.8 KBactive
SKILL.md · 812 lines · 25.8 KB
version: "1.0.1" 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 lifetimepublic static class ServiceCollectionExtensions{public static IServiceCollection AddApplicationServices(this IServiceCollection services,IConfiguration configuration){// Scoped: One instance per HTTP requestservices.AddScoped<IProductService, ProductService>();services.AddScoped<IOrderService, OrderService>();// Singleton: One instance for app lifetimeservices.AddSingleton<ICacheService, RedisCacheService>();services.AddSingleton<IConnectionMultiplexer>(_ =>ConnectionMultiplexer.Connect(configuration["Redis:Connection"]!));// Transient: New instance every timeservices.AddTransient<IValidator<CreateOrderRequest>, CreateOrderValidator>();// Options pattern for configurationservices.Configure<CatalogOptions>(configuration.GetSection("Catalog"));services.Configure<RedisOptions>(configuration.GetSection("Redis"));// Factory pattern for conditional creationservices.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 thread
4. 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){// Validationvar validation = await _validator.ValidateAsync(request, ct);if (!validation.IsValid)return Result<Order>.Failure(validation.Errors.First().ErrorMessage,"VALIDATION_ERROR");// Business rule checkvar 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 ordervar 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 assemblymodelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);// Global query filtersmodelBuilder.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, CreatedAtFROM ProductsWHERE 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, CreatedAtFROM ProductsWHERE 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 datapublic async Task<Order?> GetOrderWithItemsAsync(int orderId, CancellationToken ct = default){const string sql = """SELECT o.*, oi.*, p.*FROM Orders oLEFT JOIN OrderItems oi ON o.Id = oi.OrderIdLEFT JOIN Products p ON oi.ProductId = p.IdWHERE 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 cachesawait _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 expiredvar 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 Testpublic 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(){// Arrangevar 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" });// Actvar result = await _sut.CreateOrderAsync(request);// AssertAssert.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(){// Arrangevar 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 });// Actvar result = await _sut.CreateOrderAsync(request);// AssertAssert.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){// Arrangevar 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")}));// Actvar result = await _sut.CreateOrderAsync(request);// AssertAssert.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-memoryservices.RemoveAll<DbContextOptions<AppDbContext>>();services.AddDbContext<AppDbContext>(options =>options.UseInMemoryDatabase("TestDb"));// Replace Redis with memory cacheservices.RemoveAll<IDistributedCache>();services.AddDistributedMemoryCache();});});_client = _factory.CreateClient();}[Fact]public async Task GetProduct_WithValidId_ReturnsProduct(){// Arrangeusing 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();// Actvar response = await _client.GetAsync("/api/products/TEST-001");// Assertresponse.EnsureSuccessStatusCode();var product = await response.Content.ReadFromJsonAsync<Product>();Assert.Equal("Test Product", product!.Name);}[Fact]public async Task GetProduct_WithInvalidId_Returns404(){// Actvar response = await _client.GetAsync("/api/products/NONEXISTENT");// AssertAssert.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