dotnet-backend-patterns
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#
Install
npx skills add https://github.com/wshobson/agents/tree/main/plugins/dotnet-contribution/skills/dotnet-backend-patterns
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install wshobson-agents@llmmart
git clone https://github.com/wshobson/agents.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole wshobson/agents collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
.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
// 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<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 services
public class CheckoutService
{
public CheckoutService(
[FromKeyedServices("stripe")] IPaymentProcessor stripeProcessor)
{
_processor = stripeProcessor;
}
}
3. Async/Await Patterns
// ✅ CORRECT: Async all the way down
public async Task<Product> 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<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 caching
public 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 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
// 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<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)
// Generic Result type
public 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 service
public 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/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
// DbContext configuration
public 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 configuration
public 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 Core
public 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
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
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 pattern
public 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
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
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 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
Files (agents)
-
assets
-
repository-template.cs 16.4 KB · in bundle
-
service-template.cs 12.2 KB · in bundle
-
-
references
-
dapper-patterns.md 15.1 KB
# Dapper Patterns and Best Practices Advanced patterns for high-performance data access with Dapper in .NET. ## Why Dapper? | Aspect | Dapper | EF Core | | ---------------- | ------------------------------ | ---------------------- | | Performance | ~10x faster for simple queries | Good with optimization | | Control | Full SQL control | Abstracted | | Learning curve | Low (just SQL) | Higher | | Complex mappings | Manual | Automatic | | Change tracking | None | Built-in | | Migrations | External tools | Built-in | **Use Dapper when:** - Performance is critical (hot paths) - You need complex SQL (CTEs, window functions) - Read-heavy workloads - Legacy database schemas **Use EF Core when:** - Rich domain models with relationships - Need change tracking - Want LINQ-to-SQL translation - Complex object graphs ## Connection Management ### 1. Proper Connection Handling ```csharp // Register connection factory services.AddScoped<IDbConnection>(sp => { var connectionString = sp.GetRequiredService<IConfiguration>() .GetConnectionString("Default"); return new SqlConnection(connectionString); }); // Or use a factory for more control public interface IDbConnectionFactory { IDbConnection CreateConnection(); } public class SqlConnectionFactory : IDbConnectionFactory { private readonly string _connectionString; public SqlConnectionFactory(IConfiguration configuration) { _connectionString = configuration.GetConnectionString("Default") ?? throw new InvalidOperationException("Connection string not found"); } public IDbConnection CreateConnection() => new SqlConnection(_connectionString); } ``` ### 2. Connection Lifecycle ```csharp public class ProductRepository { private readonly IDbConnectionFactory _factory; public ProductRepository(IDbConnectionFactory factory) { _factory = factory; } public async Task<Product?> GetByIdAsync(string id, CancellationToken ct) { // Connection opens automatically, closes on dispose using var connection = _factory.CreateConnection(); return await connection.QueryFirstOrDefaultAsync<Product>( new CommandDefinition( "SELECT * FROM Products WHERE Id = @Id", new { Id = id }, cancellationToken: ct)); } } ``` ## Query Patterns ### 3. Basic CRUD Operations ```csharp // SELECT single var product = await connection.QueryFirstOrDefaultAsync<Product>( "SELECT * FROM Products WHERE Id = @Id", new { Id = id }); // SELECT multiple var products = await connection.QueryAsync<Product>( "SELECT * FROM Products WHERE CategoryId = @CategoryId", new { CategoryId = categoryId }); // INSERT with identity return var newId = await connection.QuerySingleAsync<int>( """ INSERT INTO Products (Name, Price, CategoryId) VALUES (@Name, @Price, @CategoryId); SELECT CAST(SCOPE_IDENTITY() AS INT); """, product); // INSERT with OUTPUT clause (returns full entity) var inserted = await connection.QuerySingleAsync<Product>( """ INSERT INTO Products (Name, Price, CategoryId) OUTPUT INSERTED.* VALUES (@Name, @Price, @CategoryId); """, product); // UPDATE var rowsAffected = await connection.ExecuteAsync( """ UPDATE Products SET Name = @Name, Price = @Price, UpdatedAt = @UpdatedAt WHERE Id = @Id """, new { product.Id, product.Name, product.Price, UpdatedAt = DateTime.UtcNow }); // DELETE await connection.ExecuteAsync( "DELETE FROM Products WHERE Id = @Id", new { Id = id }); ``` ### 4. Dynamic Query Building ```csharp public async Task<IReadOnlyList<Product>> SearchAsync(ProductSearchCriteria criteria) { var sql = new StringBuilder("SELECT * FROM Products WHERE 1=1"); var parameters = new DynamicParameters(); if (!string.IsNullOrWhiteSpace(criteria.SearchTerm)) { sql.Append(" AND (Name LIKE @SearchTerm OR Sku LIKE @SearchTerm)"); parameters.Add("SearchTerm", $"%{criteria.SearchTerm}%"); } if (criteria.CategoryId.HasValue) { sql.Append(" AND CategoryId = @CategoryId"); parameters.Add("CategoryId", criteria.CategoryId.Value); } if (criteria.MinPrice.HasValue) { sql.Append(" AND Price >= @MinPrice"); parameters.Add("MinPrice", criteria.MinPrice.Value); } if (criteria.MaxPrice.HasValue) { sql.Append(" AND Price <= @MaxPrice"); parameters.Add("MaxPrice", criteria.MaxPrice.Value); } // Pagination sql.Append(" ORDER BY Name"); sql.Append(" OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY"); parameters.Add("Offset", (criteria.Page - 1) * criteria.PageSize); parameters.Add("PageSize", criteria.PageSize); using var connection = _factory.CreateConnection(); var results = await connection.QueryAsync<Product>(sql.ToString(), parameters); return results.ToList(); } ``` ### 5. Multi-Mapping (Joins) ```csharp // One-to-One mapping public async Task<Product?> GetProductWithCategoryAsync(string id) { const string sql = """ SELECT p.*, c.* FROM Products p INNER JOIN Categories c ON p.CategoryId = c.Id WHERE p.Id = @Id """; using var connection = _factory.CreateConnection(); var result = await connection.QueryAsync<Product, Category, Product>( sql, (product, category) => { product.Category = category; return product; }, new { Id = id }, splitOn: "Id"); // Column where split occurs return result.FirstOrDefault(); } // One-to-Many mapping public async Task<Order?> GetOrderWithItemsAsync(int orderId) { 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>(); using var connection = _factory.CreateConnection(); await connection.QueryAsync<Order, OrderItem, Product, Order>( sql, (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; }, new { OrderId = orderId }, splitOn: "Id,Id"); return orderDictionary.Values.FirstOrDefault(); } ``` ### 6. Multiple Result Sets ```csharp public async Task<(IReadOnlyList<Product> Products, int TotalCount)> SearchWithCountAsync( ProductSearchCriteria criteria) { const string sql = """ -- First result set: count SELECT COUNT(*) FROM Products WHERE CategoryId = @CategoryId; -- Second result set: data SELECT * FROM Products WHERE CategoryId = @CategoryId ORDER BY Name OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY; """; using var connection = _factory.CreateConnection(); using var multi = await connection.QueryMultipleAsync(sql, new { CategoryId = criteria.CategoryId, Offset = (criteria.Page - 1) * criteria.PageSize, PageSize = criteria.PageSize }); var totalCount = await multi.ReadSingleAsync<int>(); var products = (await multi.ReadAsync<Product>()).ToList(); return (products, totalCount); } ``` ## Advanced Patterns ### 7. Table-Valued Parameters (Bulk Operations) ```csharp // SQL Server TVP for bulk operations public async Task<IReadOnlyList<Product>> GetByIdsAsync(IEnumerable<string> ids) { // Create DataTable matching TVP structure var table = new DataTable(); table.Columns.Add("Id", typeof(string)); foreach (var id in ids) { table.Rows.Add(id); } using var connection = _factory.CreateConnection(); var results = await connection.QueryAsync<Product>( "SELECT p.* FROM Products p INNER JOIN @Ids i ON p.Id = i.Id", new { Ids = table.AsTableValuedParameter("dbo.StringIdList") }); return results.ToList(); } // SQL to create the TVP type: // CREATE TYPE dbo.StringIdList AS TABLE (Id NVARCHAR(40)); ``` ### 8. Stored Procedures ```csharp public async Task<IReadOnlyList<Product>> GetTopProductsAsync(int categoryId, int count) { using var connection = _factory.CreateConnection(); var results = await connection.QueryAsync<Product>( "dbo.GetTopProductsByCategory", new { CategoryId = categoryId, TopN = count }, commandType: CommandType.StoredProcedure); return results.ToList(); } // With output parameters public async Task<(Order Order, string ConfirmationCode)> CreateOrderAsync(Order order) { var parameters = new DynamicParameters(new { order.CustomerId, order.Total }); parameters.Add("OrderId", dbType: DbType.Int32, direction: ParameterDirection.Output); parameters.Add("ConfirmationCode", dbType: DbType.String, size: 20, direction: ParameterDirection.Output); using var connection = _factory.CreateConnection(); await connection.ExecuteAsync( "dbo.CreateOrder", parameters, commandType: CommandType.StoredProcedure); order.Id = parameters.Get<int>("OrderId"); var confirmationCode = parameters.Get<string>("ConfirmationCode"); return (order, confirmationCode); } ``` ### 9. Transactions ```csharp public async Task<Order> CreateOrderWithItemsAsync(Order order, List<OrderItem> items) { using var connection = _factory.CreateConnection(); await connection.OpenAsync(); using var transaction = await connection.BeginTransactionAsync(); try { // Insert order order.Id = await connection.QuerySingleAsync<int>( """ INSERT INTO Orders (CustomerId, Total, CreatedAt) OUTPUT INSERTED.Id VALUES (@CustomerId, @Total, @CreatedAt) """, order, transaction); // Insert items foreach (var item in items) { item.OrderId = order.Id; } await connection.ExecuteAsync( """ INSERT INTO OrderItems (OrderId, ProductId, Quantity, UnitPrice) VALUES (@OrderId, @ProductId, @Quantity, @UnitPrice) """, items, transaction); await transaction.CommitAsync(); order.Items = items; return order; } catch { await transaction.RollbackAsync(); throw; } } ``` ### 10. Custom Type Handlers ```csharp // Register custom type handler for JSON columns public class JsonTypeHandler<T> : SqlMapper.TypeHandler<T> { public override T Parse(object value) { if (value is string json) { return JsonSerializer.Deserialize<T>(json)!; } return default!; } public override void SetValue(IDbDataParameter parameter, T value) { parameter.Value = JsonSerializer.Serialize(value); parameter.DbType = DbType.String; } } // Register at startup SqlMapper.AddTypeHandler(new JsonTypeHandler<ProductMetadata>()); // Now you can query directly var product = await connection.QueryFirstAsync<Product>( "SELECT Id, Name, Metadata FROM Products WHERE Id = @Id", new { Id = id }); // product.Metadata is automatically deserialized from JSON ``` ## Performance Tips ### 11. Use CommandDefinition for Cancellation ```csharp // Always use CommandDefinition for async operations var result = await connection.QueryAsync<Product>( new CommandDefinition( commandText: "SELECT * FROM Products WHERE CategoryId = @CategoryId", parameters: new { CategoryId = categoryId }, cancellationToken: ct, commandTimeout: 30)); ``` ### 12. Buffered vs Unbuffered Queries ```csharp // Buffered (default) - loads all results into memory var products = await connection.QueryAsync<Product>(sql); // Returns list // Unbuffered - streams results (lower memory for large result sets) var products = await connection.QueryUnbufferedAsync<Product>(sql); // Returns IAsyncEnumerable await foreach (var product in products) { // Process one at a time } ``` ### 13. Connection Pooling Settings ```json { "ConnectionStrings": { "Default": "Server=localhost;Database=MyDb;User Id=sa;Password=xxx;TrustServerCertificate=True;Min Pool Size=5;Max Pool Size=100;Connection Timeout=30;" } } ``` ## Common Patterns ### Repository Base Class ```csharp public abstract class DapperRepositoryBase<T> where T : class { protected readonly IDbConnectionFactory ConnectionFactory; protected readonly ILogger Logger; protected abstract string TableName { get; } protected DapperRepositoryBase(IDbConnectionFactory factory, ILogger logger) { ConnectionFactory = factory; Logger = logger; } protected async Task<T?> GetByIdAsync<TId>(TId id, CancellationToken ct = default) { var sql = $"SELECT * FROM {TableName} WHERE Id = @Id"; using var connection = ConnectionFactory.CreateConnection(); return await connection.QueryFirstOrDefaultAsync<T>( new CommandDefinition(sql, new { Id = id }, cancellationToken: ct)); } protected async Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct = default) { var sql = $"SELECT * FROM {TableName}"; using var connection = ConnectionFactory.CreateConnection(); var results = await connection.QueryAsync<T>( new CommandDefinition(sql, cancellationToken: ct)); return results.ToList(); } protected async Task<int> ExecuteAsync( string sql, object? parameters = null, CancellationToken ct = default) { using var connection = ConnectionFactory.CreateConnection(); return await connection.ExecuteAsync( new CommandDefinition(sql, parameters, cancellationToken: ct)); } } ``` ## Anti-Patterns to Avoid ```csharp // ❌ Bad - SQL injection risk var sql = $"SELECT * FROM Products WHERE Name = '{userInput}'"; // ✅ Good - Parameterized query var sql = "SELECT * FROM Products WHERE Name = @Name"; await connection.QueryAsync<Product>(sql, new { Name = userInput }); // ❌ Bad - Not disposing connection var connection = new SqlConnection(connectionString); var result = await connection.QueryAsync<Product>(sql); // Connection leak! // ✅ Good - Using statement using var connection = new SqlConnection(connectionString); var result = await connection.QueryAsync<Product>(sql); // ❌ Bad - Opening connection manually when not needed await connection.OpenAsync(); // Dapper does this automatically var result = await connection.QueryAsync<Product>(sql); // ✅ Good - Let Dapper manage connection var result = await connection.QueryAsync<Product>(sql); ``` -
ef-core-best-practices.md 8.7 KB
# Entity Framework Core Best Practices Performance optimization and best practices for EF Core in production applications. ## Query Optimization ### 1. Use AsNoTracking for Read-Only Queries ```csharp // ✅ Good - No change tracking overhead var products = await _context.Products .AsNoTracking() .Where(p => p.CategoryId == categoryId) .ToListAsync(ct); // ❌ Bad - Unnecessary tracking for read-only data var products = await _context.Products .Where(p => p.CategoryId == categoryId) .ToListAsync(ct); ``` ### 2. Select Only Needed Columns ```csharp // ✅ Good - Project to DTO var products = await _context.Products .AsNoTracking() .Where(p => p.CategoryId == categoryId) .Select(p => new ProductDto { Id = p.Id, Name = p.Name, Price = p.Price }) .ToListAsync(ct); // ❌ Bad - Fetching all columns var products = await _context.Products .Where(p => p.CategoryId == categoryId) .ToListAsync(ct); ``` ### 3. Avoid N+1 Queries with Eager Loading ```csharp // ✅ Good - Single query with Include var orders = await _context.Orders .AsNoTracking() .Include(o => o.Items) .ThenInclude(i => i.Product) .Where(o => o.CustomerId == customerId) .ToListAsync(ct); // ❌ Bad - N+1 queries (lazy loading) var orders = await _context.Orders .Where(o => o.CustomerId == customerId) .ToListAsync(ct); foreach (var order in orders) { // Each iteration triggers a separate query! var items = order.Items.ToList(); } ``` ### 4. Use Split Queries for Large Includes ```csharp // ✅ Good - Prevents cartesian explosion var orders = await _context.Orders .AsNoTracking() .Include(o => o.Items) .Include(o => o.Payments) .Include(o => o.ShippingHistory) .AsSplitQuery() // Executes as multiple queries .Where(o => o.CustomerId == customerId) .ToListAsync(ct); ``` ### 5. Use Compiled Queries for Hot Paths ```csharp public class ProductRepository { // Compile once, reuse many times private static readonly Func<AppDbContext, string, Task<Product?>> GetByIdQuery = EF.CompileAsyncQuery((AppDbContext ctx, string id) => ctx.Products.AsNoTracking().FirstOrDefault(p => p.Id == id)); private static readonly Func<AppDbContext, int, IAsyncEnumerable<Product>> GetByCategoryQuery = EF.CompileAsyncQuery((AppDbContext ctx, int categoryId) => ctx.Products.AsNoTracking().Where(p => p.CategoryId == categoryId)); public Task<Product?> GetByIdAsync(string id, CancellationToken ct) => GetByIdQuery(_context, id); public IAsyncEnumerable<Product> GetByCategoryAsync(int categoryId) => GetByCategoryQuery(_context, categoryId); } ``` ## Batch Operations ### 6. Use ExecuteUpdate/ExecuteDelete (.NET 7+) ```csharp // ✅ Good - Single SQL UPDATE await _context.Products .Where(p => p.CategoryId == oldCategoryId) .ExecuteUpdateAsync(s => s .SetProperty(p => p.CategoryId, newCategoryId) .SetProperty(p => p.UpdatedAt, DateTime.UtcNow), ct); // ✅ Good - Single SQL DELETE await _context.Products .Where(p => p.IsDeleted && p.UpdatedAt < cutoffDate) .ExecuteDeleteAsync(ct); // ❌ Bad - Loads all entities into memory var products = await _context.Products .Where(p => p.CategoryId == oldCategoryId) .ToListAsync(ct); foreach (var product in products) { product.CategoryId = newCategoryId; } await _context.SaveChangesAsync(ct); ``` ### 7. Bulk Insert with EFCore.BulkExtensions ```csharp // Using EFCore.BulkExtensions package var products = GenerateLargeProductList(); // ✅ Good - Bulk insert (much faster for large datasets) await _context.BulkInsertAsync(products, ct); // ❌ Bad - Individual inserts foreach (var product in products) { _context.Products.Add(product); } await _context.SaveChangesAsync(ct); ``` ## Connection Management ### 8. Configure Connection Pooling ```csharp services.AddDbContext<AppDbContext>(options => { options.UseSqlServer(connectionString, sqlOptions => { sqlOptions.EnableRetryOnFailure( maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(10), errorNumbersToAdd: null); sqlOptions.CommandTimeout(30); }); // Performance settings options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); // Development only if (env.IsDevelopment()) { options.EnableSensitiveDataLogging(); options.EnableDetailedErrors(); } }); ``` ### 9. Use DbContext Pooling ```csharp // ✅ Good - Context pooling (reduces allocation overhead) services.AddDbContextPool<AppDbContext>(options => { options.UseSqlServer(connectionString); }, poolSize: 128); // Instead of AddDbContext ``` ## Concurrency and Transactions ### 10. Handle Concurrency with Row Versioning ```csharp public class Product { public string Id { get; set; } public string Name { get; set; } [Timestamp] public byte[] RowVersion { get; set; } // SQL Server rowversion } // Or with Fluent API builder.Property(p => p.RowVersion) .IsRowVersion(); // Handle concurrency conflicts try { await _context.SaveChangesAsync(ct); } catch (DbUpdateConcurrencyException ex) { var entry = ex.Entries.Single(); var databaseValues = await entry.GetDatabaseValuesAsync(ct); if (databaseValues == null) { // Entity was deleted throw new NotFoundException("Product was deleted by another user"); } // Client wins - overwrite database values entry.OriginalValues.SetValues(databaseValues); await _context.SaveChangesAsync(ct); } ``` ### 11. Use Explicit Transactions When Needed ```csharp await using var transaction = await _context.Database.BeginTransactionAsync(ct); try { // Multiple operations _context.Orders.Add(order); await _context.SaveChangesAsync(ct); await _context.OrderItems.AddRangeAsync(items, ct); await _context.SaveChangesAsync(ct); await _paymentService.ProcessAsync(order.Id, ct); await transaction.CommitAsync(ct); } catch { await transaction.RollbackAsync(ct); throw; } ``` ## Indexing Strategy ### 12. Create Indexes for Query Patterns ```csharp public class ProductConfiguration : IEntityTypeConfiguration<Product> { public void Configure(EntityTypeBuilder<Product> builder) { // Unique index builder.HasIndex(p => p.Sku) .IsUnique(); // Composite index for common query patterns builder.HasIndex(p => new { p.CategoryId, p.Name }); // Filtered index (SQL Server) builder.HasIndex(p => p.Price) .HasFilter("[IsDeleted] = 0"); // Include columns for covering index builder.HasIndex(p => p.CategoryId) .IncludeProperties(p => new { p.Name, p.Price }); } } ``` ## Common Anti-Patterns to Avoid ### ❌ Calling ToList() Too Early ```csharp // ❌ Bad - Materializes all products then filters in memory var products = _context.Products.ToList() .Where(p => p.Price > 100); // ✅ Good - Filter in SQL var products = await _context.Products .Where(p => p.Price > 100) .ToListAsync(ct); ``` ### ❌ Using Contains with Large Collections ```csharp // ❌ Bad - Generates massive IN clause var ids = GetThousandsOfIds(); var products = await _context.Products .Where(p => ids.Contains(p.Id)) .ToListAsync(ct); // ✅ Good - Use temp table or batch queries var products = new List<Product>(); foreach (var batch in ids.Chunk(100)) { var batchResults = await _context.Products .Where(p => batch.Contains(p.Id)) .ToListAsync(ct); products.AddRange(batchResults); } ``` ### ❌ String Concatenation in Queries ```csharp // ❌ Bad - Can't use index var products = await _context.Products .Where(p => (p.FirstName + " " + p.LastName).Contains(searchTerm)) .ToListAsync(ct); // ✅ Good - Use computed column with index builder.Property(p => p.FullName) .HasComputedColumnSql("[FirstName] + ' ' + [LastName]"); builder.HasIndex(p => p.FullName); ``` ## Monitoring and Diagnostics ```csharp // Log slow queries services.AddDbContext<AppDbContext>(options => { options.UseSqlServer(connectionString); options.LogTo( filter: (eventId, level) => eventId.Id == CoreEventId.QueryExecutionPlanned.Id, logger: (eventData) => { if (eventData is QueryExpressionEventData queryData) { var duration = queryData.Duration; if (duration > TimeSpan.FromSeconds(1)) { _logger.LogWarning("Slow query detected: {Duration}ms - {Query}", duration.TotalMilliseconds, queryData.Expression); } } }); }); ```
-
-
SKILL.md 25.8 KB
--- 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<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 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<Product> 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<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 caching public 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 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<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 type public 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 service public 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/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<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 configuration public 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 Core public 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 pattern public 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 1. **Use async/await** all the way through the call stack 2. **Inject dependencies** through constructor injection 3. **Use IOptions<T>** 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
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.