mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 17:47:16 +00:00
816 lines
27 KiB
Markdown
816 lines
27 KiB
Markdown
---
|
|
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
|
|
|
|
## Resources
|
|
|
|
- **assets/service-template.cs**: Complete service implementation template
|
|
- **assets/repository-template.cs**: Repository pattern implementation
|
|
- **references/ef-core-best-practices.md**: EF Core optimization guide
|
|
- **references/dapper-patterns.md**: Advanced Dapper usage patterns
|