mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 17:47:16 +00:00
style: format all files with prettier
This commit is contained in:
@@ -59,19 +59,19 @@ public static class ServiceCollectionExtensions
|
||||
// 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 =>
|
||||
{
|
||||
@@ -80,11 +80,11 @@ public static class ServiceCollectionExtensions
|
||||
? sp.GetRequiredService<NewPriceCalculator>()
|
||||
: sp.GetRequiredService<LegacyPriceCalculator>();
|
||||
});
|
||||
|
||||
|
||||
// Keyed services (.NET 8+)
|
||||
services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("stripe");
|
||||
services.AddKeyedScoped<IPaymentProcessor, PayPalProcessor>("paypal");
|
||||
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -111,14 +111,14 @@ public async Task<Product> GetProductAsync(string id, CancellationToken ct = def
|
||||
|
||||
// ✅ CORRECT: Parallel execution with WhenAll
|
||||
public async Task<(Stock, Price)> GetStockAndPriceAsync(
|
||||
string productId,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ public ValueTask<Product?> GetCachedProductAsync(string id)
|
||||
{
|
||||
if (_cache.TryGetValue(id, out Product? product))
|
||||
return ValueTask.FromResult(product);
|
||||
|
||||
|
||||
return new ValueTask<Product?>(GetFromDatabaseAsync(id));
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ await Task.Run(async () => await GetDataAsync()); // Wastes thread
|
||||
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);
|
||||
@@ -166,7 +166,7 @@ public class CatalogOptions
|
||||
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;
|
||||
@@ -195,7 +195,7 @@ services.Configure<RedisOptions>(configuration.GetSection(RedisOptions.SectionNa
|
||||
public class CatalogService
|
||||
{
|
||||
private readonly CatalogOptions _options;
|
||||
|
||||
|
||||
public CatalogService(IOptions<CatalogOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
@@ -206,7 +206,7 @@ public class CatalogService
|
||||
public class DynamicService
|
||||
{
|
||||
private readonly CatalogOptions _options;
|
||||
|
||||
|
||||
public DynamicService(IOptionsSnapshot<CatalogOptions> options)
|
||||
{
|
||||
_options = options.Value; // Fresh value per request
|
||||
@@ -217,7 +217,7 @@ public class DynamicService
|
||||
public class MonitoredService
|
||||
{
|
||||
private CatalogOptions _options;
|
||||
|
||||
|
||||
public MonitoredService(IOptionsMonitor<CatalogOptions> monitor)
|
||||
{
|
||||
_options = monitor.CurrentValue;
|
||||
@@ -236,7 +236,7 @@ public class Result<T>
|
||||
public T? Value { get; }
|
||||
public string? Error { get; }
|
||||
public string? ErrorCode { get; }
|
||||
|
||||
|
||||
private Result(bool isSuccess, T? value, string? error, string? errorCode)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
@@ -244,13 +244,13 @@ public class Result<T>
|
||||
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);
|
||||
}
|
||||
@@ -262,19 +262,19 @@ public async Task<Result<Order>> CreateOrderAsync(CreateOrderRequest request, Ca
|
||||
var validation = await _validator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
return Result<Order>.Failure(
|
||||
validation.Errors.First().ErrorMessage,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ app.MapPost("/orders", async (
|
||||
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 });
|
||||
@@ -302,12 +302,12 @@ 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);
|
||||
}
|
||||
@@ -319,15 +319,15 @@ 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);
|
||||
@@ -338,32 +338,32 @@ public class ProductConfiguration : IEntityTypeConfiguration<Product>
|
||||
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)
|
||||
@@ -379,7 +379,7 @@ public class ProductRepository : IProductRepository
|
||||
public class DapperProductRepository : IProductRepository
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
|
||||
|
||||
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
@@ -387,11 +387,11 @@ public class DapperProductRepository : IProductRepository
|
||||
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)
|
||||
@@ -401,43 +401,43 @@ public class DapperProductRepository : IProductRepository
|
||||
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)
|
||||
{
|
||||
@@ -448,9 +448,9 @@ public class DapperProductRepository : IProductRepository
|
||||
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) =>
|
||||
@@ -461,17 +461,17 @@ public class DapperProductRepository : IProductRepository
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -488,41 +488,41 @@ public class CachedProductService : IProductService
|
||||
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,
|
||||
@@ -532,20 +532,20 @@ public class CachedProductService : IProductService
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -556,18 +556,18 @@ 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
|
||||
@@ -577,17 +577,17 @@ public class StaleWhileRevalidateCache<T>
|
||||
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;
|
||||
@@ -607,24 +607,24 @@ public class OrderServiceTests
|
||||
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()
|
||||
{
|
||||
@@ -635,52 +635,52 @@ public class OrderServiceTests
|
||||
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>()),
|
||||
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>()),
|
||||
r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
@@ -689,17 +689,17 @@ public class OrderServiceTests
|
||||
{
|
||||
// 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);
|
||||
@@ -714,7 +714,7 @@ public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
|
||||
public ProductsApiTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder =>
|
||||
@@ -725,23 +725,23 @@ public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
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",
|
||||
@@ -749,22 +749,22 @@ public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
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);
|
||||
}
|
||||
@@ -774,6 +774,7 @@ public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
## 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
|
||||
@@ -786,6 +787,7 @@ public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
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,22 +4,24 @@ 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 |
|
||||
| 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
|
||||
@@ -74,7 +76,7 @@ public class ProductRepository
|
||||
{
|
||||
// Connection opens automatically, closes on dispose
|
||||
using var connection = _factory.CreateConnection();
|
||||
|
||||
|
||||
return await connection.QueryFirstOrDefaultAsync<Product>(
|
||||
new CommandDefinition(
|
||||
"SELECT * FROM Products WHERE Id = @Id",
|
||||
@@ -120,7 +122,7 @@ var inserted = await connection.QuerySingleAsync<Product>(
|
||||
// UPDATE
|
||||
var rowsAffected = await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE Products
|
||||
UPDATE Products
|
||||
SET Name = @Name, Price = @Price, UpdatedAt = @UpdatedAt
|
||||
WHERE Id = @Id
|
||||
""",
|
||||
@@ -190,7 +192,7 @@ public async Task<Product?> GetProductWithCategoryAsync(string id)
|
||||
""";
|
||||
|
||||
using var connection = _factory.CreateConnection();
|
||||
|
||||
|
||||
var result = await connection.QueryAsync<Product, Category, Product>(
|
||||
sql,
|
||||
(product, category) =>
|
||||
@@ -218,7 +220,7 @@ public async Task<Order?> GetOrderWithItemsAsync(int orderId)
|
||||
var orderDictionary = new Dictionary<int, Order>();
|
||||
|
||||
using var connection = _factory.CreateConnection();
|
||||
|
||||
|
||||
await connection.QueryAsync<Order, OrderItem, Product, Order>(
|
||||
sql,
|
||||
(order, item, product) =>
|
||||
@@ -254,9 +256,9 @@ public async Task<(IReadOnlyList<Product> Products, int TotalCount)> SearchWithC
|
||||
const string sql = """
|
||||
-- First result set: count
|
||||
SELECT COUNT(*) FROM Products WHERE CategoryId = @CategoryId;
|
||||
|
||||
|
||||
-- Second result set: data
|
||||
SELECT * FROM Products
|
||||
SELECT * FROM Products
|
||||
WHERE CategoryId = @CategoryId
|
||||
ORDER BY Name
|
||||
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;
|
||||
@@ -288,14 +290,14 @@ 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") });
|
||||
@@ -313,7 +315,7 @@ public async Task<IReadOnlyList<Product>> GetByIdsAsync(IEnumerable<string> ids)
|
||||
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 },
|
||||
@@ -334,7 +336,7 @@ public async Task<(Order Order, string ConfirmationCode)> CreateOrderAsync(Order
|
||||
parameters.Add("ConfirmationCode", dbType: DbType.String, size: 20, direction: ParameterDirection.Output);
|
||||
|
||||
using var connection = _factory.CreateConnection();
|
||||
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.CreateOrder",
|
||||
parameters,
|
||||
@@ -354,9 +356,9 @@ public async Task<Order> CreateOrderWithItemsAsync(Order order, List<OrderItem>
|
||||
{
|
||||
using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
|
||||
|
||||
using var transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
// Insert order
|
||||
@@ -384,7 +386,7 @@ public async Task<Order> CreateOrderWithItemsAsync(Order order, List<OrderItem>
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync();
|
||||
|
||||
|
||||
order.Items = items;
|
||||
return order;
|
||||
}
|
||||
@@ -487,7 +489,7 @@ public abstract class DapperRepositoryBase<T> where T : class
|
||||
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));
|
||||
@@ -496,17 +498,17 @@ public abstract class DapperRepositoryBase<T> where T : class
|
||||
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,
|
||||
string sql,
|
||||
object? parameters = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var connection = ConnectionFactory.CreateConnection();
|
||||
|
||||
@@ -159,13 +159,13 @@ services.AddDbContext<AppDbContext>(options =>
|
||||
maxRetryCount: 3,
|
||||
maxRetryDelay: TimeSpan.FromSeconds(10),
|
||||
errorNumbersToAdd: null);
|
||||
|
||||
|
||||
sqlOptions.CommandTimeout(30);
|
||||
});
|
||||
|
||||
|
||||
// Performance settings
|
||||
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
|
||||
|
||||
|
||||
// Development only
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
@@ -196,7 +196,7 @@ public class Product
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
|
||||
|
||||
[Timestamp]
|
||||
public byte[] RowVersion { get; set; } // SQL Server rowversion
|
||||
}
|
||||
@@ -214,13 +214,13 @@ 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);
|
||||
@@ -237,12 +237,12 @@ 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
|
||||
@@ -264,14 +264,14 @@ public class ProductConfiguration : IEntityTypeConfiguration<Product>
|
||||
// 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 });
|
||||
@@ -335,7 +335,7 @@ builder.HasIndex(p => p.FullName);
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
{
|
||||
options.UseSqlServer(connectionString);
|
||||
|
||||
|
||||
options.LogTo(
|
||||
filter: (eventId, level) => eventId.Id == CoreEventId.QueryExecutionPlanned.Id,
|
||||
logger: (eventData) =>
|
||||
|
||||
Reference in New Issue
Block a user