diff --git a/plugins/dotnet-contribution/README.md b/plugins/dotnet-contribution/README.md new file mode 100644 index 0000000..66c5cd3 --- /dev/null +++ b/plugins/dotnet-contribution/README.md @@ -0,0 +1,127 @@ +# .NET Backend Development Plugin + +A comprehensive plugin for .NET backend development with C#, ASP.NET Core, Entity Framework Core, and Dapper. + +## Overview + +This plugin provides agents, skills, and patterns for building production-grade .NET applications. It focuses on modern C# (12/13), ASP.NET Core 8+, and enterprise development patterns. + +## Contents + +### Agents + +| Agent | Model | Description | +|-------|-------|-------------| +| `dotnet-architect` | Sonnet | Expert .NET architect for API development, code review, and architecture decisions | + +### Skills + +| Skill | Description | +|-------|-------------| +| `dotnet-backend-patterns` | Comprehensive patterns for services, repositories, DI, caching, and testing | + +### Assets + +- `service-template.cs` - Complete service implementation with Result pattern, validation, caching +- `repository-template.cs` - Repository implementations with Dapper and EF Core + +### References + +- `ef-core-best-practices.md` - EF Core optimization guide +- `dapper-patterns.md` - Advanced Dapper usage patterns + +## Usage + +### With Claude Code CLI + +```bash +# General .NET architecture help +claude -p "Act as dotnet-architect and design a caching strategy for my product catalog" + +# Code review +claude -p "Act as dotnet-architect and review this async code for issues" + +# Implementation help +claude -p "Use dotnet-backend-patterns skill to implement a repository with Dapper" +``` + +### Example Prompts + +1. **API Design** + ``` + Act as dotnet-architect. Design a REST API for order management with proper + DTOs, validation, and error handling. + ``` + +2. **Performance Review** + ``` + Act as dotnet-architect. Review this EF Core query for N+1 problems and + suggest optimizations. + ``` + +3. **Architecture Decision** + ``` + Act as dotnet-architect. Should I use EF Core or Dapper for this high-throughput + read scenario? Explain trade-offs. + ``` + +## Topics Covered + +### C# Language +- Async/await patterns and pitfalls +- LINQ optimization +- Records and immutability +- Pattern matching +- Nullable reference types +- Memory-efficient programming + +### ASP.NET Core +- Minimal APIs and Controllers +- Dependency Injection (Scoped, Singleton, Transient, Keyed) +- Configuration with IOptions +- Middleware pipeline +- Authentication/Authorization +- Health checks + +### Data Access +- Entity Framework Core best practices +- Dapper for high-performance queries +- Repository pattern +- Unit of Work +- Connection management +- Transaction handling + +### Caching +- IMemoryCache +- IDistributedCache with Redis +- Multi-level caching +- Cache invalidation +- Distributed locking + +### Testing +- xUnit fundamentals +- Moq for mocking +- Integration tests with WebApplicationFactory +- Test patterns and best practices + +## Stack Compatibility + +| Technology | Version | +|------------|---------| +| .NET | 8.0+ | +| C# | 12+ | +| ASP.NET Core | 8.0+ | +| Entity Framework Core | 8.0+ | +| SQL Server | 2019+ | +| Redis | 6.0+ | + +## Contributing + +Contributions welcome! Please ensure: +- Code examples compile and follow C# conventions +- Patterns are production-tested +- Documentation is clear and includes examples + +## License + +MIT License - See repository root for details. diff --git a/plugins/dotnet-contribution/agents/dotnet-architect.md b/plugins/dotnet-contribution/agents/dotnet-architect.md new file mode 100644 index 0000000..b11841f --- /dev/null +++ b/plugins/dotnet-contribution/agents/dotnet-architect.md @@ -0,0 +1,175 @@ +--- +name: dotnet-architect +description: Expert .NET backend architect specializing in C#, ASP.NET Core, Entity Framework, Dapper, and enterprise application patterns. Masters async/await, dependency injection, caching strategies, and performance optimization. Use PROACTIVELY for .NET API development, code review, or architecture decisions. +model: sonnet +--- + +You are an expert .NET backend architect with deep knowledge of C#, ASP.NET Core, and enterprise application patterns. + +## Purpose + +Senior .NET architect focused on building production-grade APIs, microservices, and enterprise applications. Combines deep expertise in C# language features, ASP.NET Core framework, data access patterns, and cloud-native development to deliver robust, maintainable, and high-performance solutions. + +## Capabilities + +### C# Language Mastery +- Modern C# features (12/13): required members, primary constructors, collection expressions +- Async/await patterns: ValueTask, IAsyncEnumerable, ConfigureAwait +- LINQ optimization: deferred execution, expression trees, avoiding materializations +- Memory management: Span, Memory, ArrayPool, stackalloc +- Pattern matching: switch expressions, property patterns, list patterns +- Records and immutability: record types, init-only setters, with expressions +- Nullable reference types: proper annotation and handling + +### ASP.NET Core Expertise +- Minimal APIs and controller-based APIs +- Middleware pipeline and request processing +- Dependency injection: lifetimes, keyed services, factory patterns +- Configuration: IOptions, IOptionsSnapshot, IOptionsMonitor +- Authentication/Authorization: JWT, OAuth, policy-based auth +- Health checks and readiness/liveness probes +- Background services and hosted services +- Rate limiting and output caching + +### Data Access Patterns +- Entity Framework Core: DbContext, configurations, migrations +- EF Core optimization: AsNoTracking, split queries, compiled queries +- Dapper: high-performance queries, multi-mapping, TVPs +- Repository and Unit of Work patterns +- CQRS: command/query separation +- Database-first vs code-first approaches +- Connection pooling and transaction management + +### Caching Strategies +- IMemoryCache for in-process caching +- IDistributedCache with Redis +- Multi-level caching (L1/L2) +- Stale-while-revalidate patterns +- Cache invalidation strategies +- Distributed locking with Redis + +### Performance Optimization +- Profiling and benchmarking with BenchmarkDotNet +- Memory allocation analysis +- HTTP client optimization with IHttpClientFactory +- Response compression and streaming +- Database query optimization +- Reducing GC pressure + +### Testing Practices +- xUnit test framework +- Moq for mocking dependencies +- FluentAssertions for readable assertions +- Integration tests with WebApplicationFactory +- Test containers for database tests +- Code coverage with Coverlet + +### Architecture Patterns +- Clean Architecture / Onion Architecture +- Domain-Driven Design (DDD) tactical patterns +- CQRS with MediatR +- Event sourcing basics +- Microservices patterns: API Gateway, Circuit Breaker +- Vertical slice architecture + +### DevOps & Deployment +- Docker containerization for .NET +- Kubernetes deployment patterns +- CI/CD with GitHub Actions / Azure DevOps +- Health monitoring with Application Insights +- Structured logging with Serilog +- OpenTelemetry integration + +## Behavioral Traits + +- Writes idiomatic, modern C# code following Microsoft guidelines +- Favors composition over inheritance +- Applies SOLID principles pragmatically +- Prefers explicit over implicit (nullable annotations, explicit types when clearer) +- Values testability and designs for dependency injection +- Considers performance implications but avoids premature optimization +- Uses async/await correctly throughout the call stack +- Prefers records for DTOs and immutable data structures +- Documents public APIs with XML comments +- Handles errors gracefully with Result types or exceptions as appropriate + +## Knowledge Base + +- Microsoft .NET documentation and best practices +- ASP.NET Core fundamentals and advanced topics +- Entity Framework Core and Dapper patterns +- Redis caching and distributed systems +- xUnit, Moq, and testing strategies +- Clean Architecture and DDD patterns +- Performance optimization techniques +- Security best practices for .NET applications + +## Response Approach + +1. **Understand requirements** including performance, scale, and maintainability needs +2. **Design architecture** with appropriate patterns for the problem +3. **Implement with best practices** using modern C# and .NET features +4. **Optimize for performance** where it matters (hot paths, data access) +5. **Ensure testability** with proper abstractions and DI +6. **Document decisions** with clear code comments and README +7. **Consider edge cases** including error handling and concurrency +8. **Review for security** applying OWASP guidelines + +## Example Interactions + +- "Design a caching strategy for product catalog with 100K items" +- "Review this async code for potential deadlocks and performance issues" +- "Implement a repository pattern with both EF Core and Dapper" +- "Optimize this LINQ query that's causing N+1 problems" +- "Create a background service for processing order queue" +- "Design authentication flow with JWT and refresh tokens" +- "Set up health checks for API and database dependencies" +- "Implement rate limiting for public API endpoints" + +## Code Style Preferences + +```csharp +// ✅ Preferred: Modern C# with clear intent +public sealed class ProductService( + IProductRepository repository, + ICacheService cache, + ILogger logger) : IProductService +{ + public async Task> GetByIdAsync( + string id, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + + var cached = await cache.GetAsync($"product:{id}", ct); + if (cached is not null) + return Result.Success(cached); + + var product = await repository.GetByIdAsync(id, ct); + + return product is not null + ? Result.Success(product) + : Result.Failure("Product not found", "NOT_FOUND"); + } +} + +// ✅ Preferred: Record types for DTOs +public sealed record CreateProductRequest( + string Name, + string Sku, + decimal Price, + int CategoryId); + +// ✅ Preferred: Expression-bodied members when simple +public string FullName => $"{FirstName} {LastName}"; + +// ✅ Preferred: Pattern matching +var status = order.State switch +{ + OrderState.Pending => "Awaiting payment", + OrderState.Confirmed => "Order confirmed", + OrderState.Shipped => "In transit", + OrderState.Delivered => "Delivered", + _ => "Unknown" +}; +``` diff --git a/plugins/dotnet-contribution/skills/dotnet-backend-patterns/SKILL.md b/plugins/dotnet-contribution/skills/dotnet-backend-patterns/SKILL.md new file mode 100644 index 0000000..6b3afba --- /dev/null +++ b/plugins/dotnet-contribution/skills/dotnet-backend-patterns/SKILL.md @@ -0,0 +1,815 @@ +--- +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(); + services.AddScoped(); + + // Singleton: One instance for app lifetime + services.AddSingleton(); + services.AddSingleton(_ => + ConnectionMultiplexer.Connect(configuration["Redis:Connection"]!)); + + // Transient: New instance every time + services.AddTransient, CreateOrderValidator>(); + + // Options pattern for configuration + services.Configure(configuration.GetSection("Catalog")); + services.Configure(configuration.GetSection("Redis")); + + // Factory pattern for conditional creation + services.AddScoped(sp => + { + var options = sp.GetRequiredService>().Value; + return options.UseNewEngine + ? sp.GetRequiredService() + : sp.GetRequiredService(); + }); + + // Keyed services (.NET 8+) + services.AddKeyedScoped("stripe"); + services.AddKeyedScoped("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 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 LibraryMethodAsync(CancellationToken ct = default) +{ + var result = await _httpClient.GetAsync(url, ct).ConfigureAwait(false); + return await result.Content.ReadFromJsonAsync(ct).ConfigureAwait(false); +} + +// ✅ CORRECT: ValueTask for hot paths with caching +public ValueTask GetCachedProductAsync(string id) +{ + if (_cache.TryGetValue(id, out Product? product)) + return ValueTask.FromResult(product); + + return new ValueTask(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(configuration.GetSection(CatalogOptions.SectionName)); +services.Configure(configuration.GetSection(RedisOptions.SectionName)); + +// Usage with IOptions (singleton, read once at startup) +public class CatalogService +{ + private readonly CatalogOptions _options; + + public CatalogService(IOptions options) + { + _options = options.Value; + } +} + +// Usage with IOptionsSnapshot (scoped, re-reads on each request) +public class DynamicService +{ + private readonly CatalogOptions _options; + + public DynamicService(IOptionsSnapshot options) + { + _options = options.Value; // Fresh value per request + } +} + +// Usage with IOptionsMonitor (singleton, notified on changes) +public class MonitoredService +{ + private CatalogOptions _options; + + public MonitoredService(IOptionsMonitor monitor) + { + _options = monitor.CurrentValue; + monitor.OnChange(newOptions => _options = newOptions); + } +} +``` + +### 5. Result Pattern (Avoiding Exceptions for Flow Control) + +```csharp +// Generic Result type +public class Result +{ + 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 Success(T value) => new(true, value, null, null); + public static Result Failure(string error, string? code = null) => new(false, default, error, code); + + public Result Map(Func mapper) => + IsSuccess ? Result.Success(mapper(Value!)) : Result.Failure(Error!, ErrorCode); + + public async Task> MapAsync(Func> mapper) => + IsSuccess ? Result.Success(await mapper(Value!)) : Result.Failure(Error!, ErrorCode); +} + +// Usage in service +public async Task> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct) +{ + // Validation + var validation = await _validator.ValidateAsync(request, ct); + if (!validation.IsValid) + return Result.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.Failure( + $"Insufficient stock: {stock.Available} available, {request.Quantity} requested", + "INSUFFICIENT_STOCK"); + + // Create order + var order = await _repository.CreateAsync(request.ToEntity(), ct); + + return Result.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 Products => Set(); + public DbSet Orders => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Apply all configurations from assembly + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + + // Global query filters + modelBuilder.Entity().HasQueryFilter(p => !p.IsDeleted); + } +} + +// Entity configuration +public class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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 GetByIdAsync(string id, CancellationToken ct = default) + { + return await _context.Products + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == id, ct); + } + + public async Task> 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 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( + new CommandDefinition(sql, new { Id = id }, cancellationToken: ct)); + } + + public async Task> 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( + new CommandDefinition(sql.ToString(), parameters, cancellationToken: ct)); + + return results.ToList(); + } + + // Multi-mapping for related data + public async Task 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(); + + await _connection.QueryAsync( + 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(); + 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 _logger; + + private static readonly TimeSpan MemoryCacheDuration = TimeSpan.FromMinutes(1); + private static readonly TimeSpan DistributedCacheDuration = TimeSpan.FromMinutes(15); + + public async Task 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(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 +{ + private readonly IDistributedCache _cache; + private readonly TimeSpan _freshDuration; + private readonly TimeSpan _staleDuration; + + public async Task GetOrCreateAsync( + string key, + Func> factory, + CancellationToken ct = default) + { + var cached = await _cache.GetStringAsync(key, ct); + + if (cached != null) + { + var entry = JsonSerializer.Deserialize>(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 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 _mockRepository; + private readonly Mock _mockStockService; + private readonly Mock> _mockValidator; + private readonly OrderService _sut; // System Under Test + + public OrderServiceTests() + { + _mockRepository = new Mock(); + _mockStockService = new Mock(); + _mockValidator = new Mock>(); + + // Default: validation passes + _mockValidator + .Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) + .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())) + .ReturnsAsync(new StockResult { IsAvailable = true, Available = 10 }); + + _mockRepository + .Setup(r => r.CreateAsync(It.IsAny(), It.IsAny())) + .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(o => o.CustomerOrderCode == "ORD-2024-001"), + It.IsAny()), + 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(), It.IsAny(), It.IsAny())) + .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(), It.IsAny()), + 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())) + .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> +{ + private readonly WebApplicationFactory _factory; + private readonly HttpClient _client; + + public ProductsApiTests(WebApplicationFactory factory) + { + _factory = factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + // Replace real database with in-memory + services.RemoveAll>(); + services.AddDbContext(options => + options.UseInMemoryDatabase("TestDb")); + + // Replace Redis with memory cache + services.RemoveAll(); + services.AddDistributedMemoryCache(); + }); + }); + + _client = _factory.CreateClient(); + } + + [Fact] + public async Task GetProduct_WithValidId_ReturnsProduct() + { + // Arrange + using var scope = _factory.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + 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(); + 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** 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 diff --git a/plugins/dotnet-contribution/skills/dotnet-backend-patterns/assets/repository-template.cs b/plugins/dotnet-contribution/skills/dotnet-backend-patterns/assets/repository-template.cs new file mode 100644 index 0000000..2e73099 --- /dev/null +++ b/plugins/dotnet-contribution/skills/dotnet-backend-patterns/assets/repository-template.cs @@ -0,0 +1,523 @@ +// Repository Implementation Template for .NET 8+ +// Demonstrates both Dapper (performance) and EF Core (convenience) patterns + +using System.Data; +using Dapper; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace YourNamespace.Infrastructure.Data; + +#region Interfaces + +public interface IProductRepository +{ + Task GetByIdAsync(string id, CancellationToken ct = default); + Task GetBySkuAsync(string sku, CancellationToken ct = default); + Task<(IReadOnlyList Items, int TotalCount)> SearchAsync(ProductSearchRequest request, CancellationToken ct = default); + Task CreateAsync(Product product, CancellationToken ct = default); + Task UpdateAsync(Product product, CancellationToken ct = default); + Task DeleteAsync(string id, CancellationToken ct = default); + Task> GetByIdsAsync(IEnumerable ids, CancellationToken ct = default); +} + +#endregion + +#region Dapper Implementation (High Performance) + +public class DapperProductRepository : IProductRepository +{ + private readonly IDbConnection _connection; + private readonly ILogger _logger; + + public DapperProductRepository( + IDbConnection connection, + ILogger logger) + { + _connection = connection; + _logger = logger; + } + + public async Task GetByIdAsync(string id, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt, UpdatedAt + FROM Products + WHERE Id = @Id AND IsDeleted = 0 + """; + + return await _connection.QueryFirstOrDefaultAsync( + new CommandDefinition(sql, new { Id = id }, cancellationToken: ct)); + } + + public async Task GetBySkuAsync(string sku, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt, UpdatedAt + FROM Products + WHERE Sku = @Sku AND IsDeleted = 0 + """; + + return await _connection.QueryFirstOrDefaultAsync( + new CommandDefinition(sql, new { Sku = sku }, cancellationToken: ct)); + } + + public async Task<(IReadOnlyList Items, int TotalCount)> SearchAsync( + ProductSearchRequest request, + CancellationToken ct = default) + { + var whereClauses = new List { "IsDeleted = 0" }; + var parameters = new DynamicParameters(); + + // Build dynamic WHERE clause + if (!string.IsNullOrWhiteSpace(request.SearchTerm)) + { + whereClauses.Add("(Name LIKE @SearchTerm OR Sku LIKE @SearchTerm)"); + parameters.Add("SearchTerm", $"%{request.SearchTerm}%"); + } + + if (request.CategoryId.HasValue) + { + whereClauses.Add("CategoryId = @CategoryId"); + parameters.Add("CategoryId", request.CategoryId.Value); + } + + if (request.MinPrice.HasValue) + { + whereClauses.Add("Price >= @MinPrice"); + parameters.Add("MinPrice", request.MinPrice.Value); + } + + if (request.MaxPrice.HasValue) + { + whereClauses.Add("Price <= @MaxPrice"); + parameters.Add("MaxPrice", request.MaxPrice.Value); + } + + var whereClause = string.Join(" AND ", whereClauses); + var page = request.Page ?? 1; + var pageSize = request.PageSize ?? 50; + var offset = (page - 1) * pageSize; + + parameters.Add("Offset", offset); + parameters.Add("PageSize", pageSize); + + // Use multi-query for count + data in single roundtrip + var sql = $""" + -- Count query + SELECT COUNT(*) FROM Products WHERE {whereClause}; + + -- Data query with pagination + SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt, UpdatedAt + FROM Products + WHERE {whereClause} + ORDER BY Name + OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY; + """; + + using var multi = await _connection.QueryMultipleAsync( + new CommandDefinition(sql, parameters, cancellationToken: ct)); + + var totalCount = await multi.ReadSingleAsync(); + var items = (await multi.ReadAsync()).ToList(); + + return (items, totalCount); + } + + public async Task CreateAsync(Product product, CancellationToken ct = default) + { + const string sql = """ + INSERT INTO Products (Id, Name, Sku, Price, CategoryId, Stock, CreatedAt, IsDeleted) + VALUES (@Id, @Name, @Sku, @Price, @CategoryId, @Stock, @CreatedAt, 0); + + SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt, UpdatedAt + FROM Products WHERE Id = @Id; + """; + + return await _connection.QuerySingleAsync( + new CommandDefinition(sql, product, cancellationToken: ct)); + } + + public async Task UpdateAsync(Product product, CancellationToken ct = default) + { + const string sql = """ + UPDATE Products + SET Name = @Name, + Sku = @Sku, + Price = @Price, + CategoryId = @CategoryId, + Stock = @Stock, + UpdatedAt = @UpdatedAt + WHERE Id = @Id AND IsDeleted = 0; + + SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt, UpdatedAt + FROM Products WHERE Id = @Id; + """; + + return await _connection.QuerySingleAsync( + new CommandDefinition(sql, product, cancellationToken: ct)); + } + + public async Task DeleteAsync(string id, CancellationToken ct = default) + { + const string sql = """ + UPDATE Products + SET IsDeleted = 1, UpdatedAt = @UpdatedAt + WHERE Id = @Id + """; + + await _connection.ExecuteAsync( + new CommandDefinition(sql, new { Id = id, UpdatedAt = DateTime.UtcNow }, cancellationToken: ct)); + } + + public async Task> GetByIdsAsync( + IEnumerable ids, + CancellationToken ct = default) + { + var idList = ids.ToList(); + if (idList.Count == 0) + return Array.Empty(); + + const string sql = """ + SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt, UpdatedAt + FROM Products + WHERE Id IN @Ids AND IsDeleted = 0 + """; + + var results = await _connection.QueryAsync( + new CommandDefinition(sql, new { Ids = idList }, cancellationToken: ct)); + + return results.ToList(); + } +} + +#endregion + +#region EF Core Implementation (Rich Domain Models) + +public class EfCoreProductRepository : IProductRepository +{ + private readonly AppDbContext _context; + private readonly ILogger _logger; + + public EfCoreProductRepository( + AppDbContext context, + ILogger logger) + { + _context = context; + _logger = logger; + } + + public async Task GetByIdAsync(string id, CancellationToken ct = default) + { + return await _context.Products + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == id, ct); + } + + public async Task GetBySkuAsync(string sku, CancellationToken ct = default) + { + return await _context.Products + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Sku == sku, ct); + } + + public async Task<(IReadOnlyList Items, int TotalCount)> SearchAsync( + ProductSearchRequest request, + CancellationToken ct = default) + { + var query = _context.Products.AsNoTracking(); + + // Apply filters + if (!string.IsNullOrWhiteSpace(request.SearchTerm)) + { + var term = request.SearchTerm.ToLower(); + query = query.Where(p => + p.Name.ToLower().Contains(term) || + p.Sku.ToLower().Contains(term)); + } + + if (request.CategoryId.HasValue) + query = query.Where(p => p.CategoryId == request.CategoryId.Value); + + if (request.MinPrice.HasValue) + query = query.Where(p => p.Price >= request.MinPrice.Value); + + if (request.MaxPrice.HasValue) + query = query.Where(p => p.Price <= request.MaxPrice.Value); + + // Get count before pagination + var totalCount = await query.CountAsync(ct); + + // Apply pagination + var page = request.Page ?? 1; + var pageSize = request.PageSize ?? 50; + + var items = await query + .OrderBy(p => p.Name) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(ct); + + return (items, totalCount); + } + + public async Task CreateAsync(Product product, CancellationToken ct = default) + { + _context.Products.Add(product); + await _context.SaveChangesAsync(ct); + return product; + } + + public async Task UpdateAsync(Product product, CancellationToken ct = default) + { + _context.Products.Update(product); + await _context.SaveChangesAsync(ct); + return product; + } + + public async Task DeleteAsync(string id, CancellationToken ct = default) + { + var product = await _context.Products.FindAsync(new object[] { id }, ct); + if (product != null) + { + product.IsDeleted = true; + product.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(ct); + } + } + + public async Task> GetByIdsAsync( + IEnumerable ids, + CancellationToken ct = default) + { + var idList = ids.ToList(); + if (idList.Count == 0) + return Array.Empty(); + + return await _context.Products + .AsNoTracking() + .Where(p => idList.Contains(p.Id)) + .ToListAsync(ct); + } +} + +#endregion + +#region DbContext Configuration + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet Products => Set(); + public DbSet Categories => Set(); + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Apply all configurations from assembly + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + + // Global query filter for soft delete + modelBuilder.Entity().HasQueryFilter(p => !p.IsDeleted); + } +} + +public class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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.Sku) + .HasMaxLength(50) + .IsRequired(); + + builder.Property(p => p.Price) + .HasPrecision(18, 2); + + // Indexes + builder.HasIndex(p => p.Sku).IsUnique(); + builder.HasIndex(p => p.CategoryId); + builder.HasIndex(p => new { p.CategoryId, p.Name }); + + // Relationships + builder.HasOne(p => p.Category) + .WithMany(c => c.Products) + .HasForeignKey(p => p.CategoryId); + } +} + +#endregion + +#region Advanced Patterns + +/// +/// Unit of Work pattern for coordinating multiple repositories +/// +public interface IUnitOfWork : IDisposable +{ + IProductRepository Products { get; } + IOrderRepository Orders { get; } + Task SaveChangesAsync(CancellationToken ct = default); + Task BeginTransactionAsync(CancellationToken ct = default); + Task CommitAsync(CancellationToken ct = default); + Task RollbackAsync(CancellationToken ct = default); +} + +public class UnitOfWork : IUnitOfWork +{ + private readonly AppDbContext _context; + private IDbContextTransaction? _transaction; + + public IProductRepository Products { get; } + public IOrderRepository Orders { get; } + + public UnitOfWork( + AppDbContext context, + IProductRepository products, + IOrderRepository orders) + { + _context = context; + Products = products; + Orders = orders; + } + + public async Task SaveChangesAsync(CancellationToken ct = default) + => await _context.SaveChangesAsync(ct); + + public async Task BeginTransactionAsync(CancellationToken ct = default) + { + _transaction = await _context.Database.BeginTransactionAsync(ct); + } + + public async Task CommitAsync(CancellationToken ct = default) + { + if (_transaction != null) + { + await _transaction.CommitAsync(ct); + await _transaction.DisposeAsync(); + _transaction = null; + } + } + + public async Task RollbackAsync(CancellationToken ct = default) + { + if (_transaction != null) + { + await _transaction.RollbackAsync(ct); + await _transaction.DisposeAsync(); + _transaction = null; + } + } + + public void Dispose() + { + _transaction?.Dispose(); + _context.Dispose(); + } +} + +/// +/// Specification pattern for complex queries +/// +public interface ISpecification +{ + Expression> Criteria { get; } + List>> Includes { get; } + List IncludeStrings { get; } + Expression>? OrderBy { get; } + Expression>? OrderByDescending { get; } + int? Take { get; } + int? Skip { get; } +} + +public abstract class BaseSpecification : ISpecification +{ + public Expression> Criteria { get; private set; } = _ => true; + public List>> Includes { get; } = new(); + public List IncludeStrings { get; } = new(); + public Expression>? OrderBy { get; private set; } + public Expression>? OrderByDescending { get; private set; } + public int? Take { get; private set; } + public int? Skip { get; private set; } + + protected void AddCriteria(Expression> criteria) => Criteria = criteria; + protected void AddInclude(Expression> include) => Includes.Add(include); + protected void AddInclude(string include) => IncludeStrings.Add(include); + protected void ApplyOrderBy(Expression> orderBy) => OrderBy = orderBy; + protected void ApplyOrderByDescending(Expression> orderBy) => OrderByDescending = orderBy; + protected void ApplyPaging(int skip, int take) { Skip = skip; Take = take; } +} + +// Example specification +public class ProductsByCategorySpec : BaseSpecification +{ + public ProductsByCategorySpec(int categoryId, int page, int pageSize) + { + AddCriteria(p => p.CategoryId == categoryId); + AddInclude(p => p.Category); + ApplyOrderBy(p => p.Name); + ApplyPaging((page - 1) * pageSize, pageSize); + } +} + +#endregion + +#region Entity Definitions + +public class Product +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Sku { get; set; } = string.Empty; + public decimal Price { get; set; } + public int CategoryId { get; set; } + public int Stock { get; set; } + public bool IsDeleted { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + // Navigation + public Category? Category { get; set; } +} + +public class Category +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public ICollection Products { get; set; } = new List(); +} + +public class Order +{ + public int Id { get; set; } + public string CustomerOrderCode { get; set; } = string.Empty; + public decimal Total { get; set; } + public DateTime CreatedAt { get; set; } + public ICollection Items { get; set; } = new List(); +} + +public class OrderItem +{ + public int Id { get; set; } + public int OrderId { get; set; } + public string ProductId { get; set; } = string.Empty; + public int Quantity { get; set; } + public decimal UnitPrice { get; set; } + + public Order? Order { get; set; } + public Product? Product { get; set; } +} + +#endregion diff --git a/plugins/dotnet-contribution/skills/dotnet-backend-patterns/assets/service-template.cs b/plugins/dotnet-contribution/skills/dotnet-backend-patterns/assets/service-template.cs new file mode 100644 index 0000000..8fb7e73 --- /dev/null +++ b/plugins/dotnet-contribution/skills/dotnet-backend-patterns/assets/service-template.cs @@ -0,0 +1,336 @@ +// Service Implementation Template for .NET 8+ +// This template demonstrates best practices for building robust services + +using System.Text.Json; +using FluentValidation; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace YourNamespace.Application.Services; + +/// +/// Configuration options for the service +/// +public class ProductServiceOptions +{ + public const string SectionName = "ProductService"; + + 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; +} + +/// +/// Generic result type for operations that can fail +/// +public class Result +{ + 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 Success(T value) => new(true, value, null, null); + public static Result Failure(string error, string? code = null) => new(false, default, error, code); + + public Result Map(Func mapper) => + IsSuccess ? Result.Success(mapper(Value!)) : Result.Failure(Error!, ErrorCode); +} + +/// +/// Service interface - define the contract +/// +public interface IProductService +{ + Task> GetByIdAsync(string id, CancellationToken ct = default); + Task>> SearchAsync(ProductSearchRequest request, CancellationToken ct = default); + Task> CreateAsync(CreateProductRequest request, CancellationToken ct = default); + Task> UpdateAsync(string id, UpdateProductRequest request, CancellationToken ct = default); + Task> DeleteAsync(string id, CancellationToken ct = default); +} + +/// +/// Service implementation with full patterns +/// +public class ProductService : IProductService +{ + private readonly IProductRepository _repository; + private readonly ICacheService _cache; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + private readonly ILogger _logger; + private readonly ProductServiceOptions _options; + + public ProductService( + IProductRepository repository, + ICacheService cache, + IValidator createValidator, + IValidator updateValidator, + ILogger logger, + IOptions options) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _createValidator = createValidator ?? throw new ArgumentNullException(nameof(createValidator)); + _updateValidator = updateValidator ?? throw new ArgumentNullException(nameof(updateValidator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + public async Task> GetByIdAsync(string id, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(id)) + return Result.Failure("Product ID is required", "INVALID_ID"); + + try + { + // Try cache first + var cacheKey = GetCacheKey(id); + var cached = await _cache.GetAsync(cacheKey, ct); + + if (cached != null) + { + _logger.LogDebug("Cache hit for product {ProductId}", id); + return Result.Success(cached); + } + + // Fetch from repository + var product = await _repository.GetByIdAsync(id, ct); + + if (product == null) + { + _logger.LogWarning("Product not found: {ProductId}", id); + return Result.Failure($"Product '{id}' not found", "NOT_FOUND"); + } + + // Populate cache + await _cache.SetAsync(cacheKey, product, _options.CacheDuration, ct); + + return Result.Success(product); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving product {ProductId}", id); + return Result.Failure("An error occurred while retrieving the product", "INTERNAL_ERROR"); + } + } + + public async Task>> SearchAsync( + ProductSearchRequest request, + CancellationToken ct = default) + { + try + { + // Sanitize pagination + var pageSize = Math.Clamp(request.PageSize ?? _options.DefaultPageSize, 1, _options.MaxPageSize); + var page = Math.Max(request.Page ?? 1, 1); + + var sanitizedRequest = request with + { + PageSize = pageSize, + Page = page + }; + + // Execute search + var (items, totalCount) = await _repository.SearchAsync(sanitizedRequest, ct); + + var result = new PagedResult + { + Items = items, + TotalCount = totalCount, + Page = page, + PageSize = pageSize, + TotalPages = (int)Math.Ceiling((double)totalCount / pageSize) + }; + + return Result>.Success(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching products with request {@Request}", request); + return Result>.Failure("An error occurred while searching products", "INTERNAL_ERROR"); + } + } + + public async Task> CreateAsync(CreateProductRequest request, CancellationToken ct = default) + { + // Validate + var validation = await _createValidator.ValidateAsync(request, ct); + if (!validation.IsValid) + { + var errors = string.Join("; ", validation.Errors.Select(e => e.ErrorMessage)); + return Result.Failure(errors, "VALIDATION_ERROR"); + } + + try + { + // Check for duplicates + var existing = await _repository.GetBySkuAsync(request.Sku, ct); + if (existing != null) + return Result.Failure($"Product with SKU '{request.Sku}' already exists", "DUPLICATE_SKU"); + + // Create entity + var product = new Product + { + Id = Guid.NewGuid().ToString("N"), + Name = request.Name, + Sku = request.Sku, + Price = request.Price, + CategoryId = request.CategoryId, + CreatedAt = DateTime.UtcNow + }; + + // Persist + var created = await _repository.CreateAsync(product, ct); + + _logger.LogInformation("Created product {ProductId} with SKU {Sku}", created.Id, created.Sku); + + return Result.Success(created); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating product with SKU {Sku}", request.Sku); + return Result.Failure("An error occurred while creating the product", "INTERNAL_ERROR"); + } + } + + public async Task> UpdateAsync( + string id, + UpdateProductRequest request, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(id)) + return Result.Failure("Product ID is required", "INVALID_ID"); + + // Validate + var validation = await _updateValidator.ValidateAsync(request, ct); + if (!validation.IsValid) + { + var errors = string.Join("; ", validation.Errors.Select(e => e.ErrorMessage)); + return Result.Failure(errors, "VALIDATION_ERROR"); + } + + try + { + // Fetch existing + var existing = await _repository.GetByIdAsync(id, ct); + if (existing == null) + return Result.Failure($"Product '{id}' not found", "NOT_FOUND"); + + // Apply updates (only non-null values) + if (request.Name != null) existing.Name = request.Name; + if (request.Price.HasValue) existing.Price = request.Price.Value; + if (request.CategoryId.HasValue) existing.CategoryId = request.CategoryId.Value; + existing.UpdatedAt = DateTime.UtcNow; + + // Persist + var updated = await _repository.UpdateAsync(existing, ct); + + // Invalidate cache + await _cache.RemoveAsync(GetCacheKey(id), ct); + + _logger.LogInformation("Updated product {ProductId}", id); + + return Result.Success(updated); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating product {ProductId}", id); + return Result.Failure("An error occurred while updating the product", "INTERNAL_ERROR"); + } + } + + public async Task> DeleteAsync(string id, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(id)) + return Result.Failure("Product ID is required", "INVALID_ID"); + + try + { + var existing = await _repository.GetByIdAsync(id, ct); + if (existing == null) + return Result.Failure($"Product '{id}' not found", "NOT_FOUND"); + + // Soft delete + await _repository.DeleteAsync(id, ct); + + // Invalidate cache + await _cache.RemoveAsync(GetCacheKey(id), ct); + + _logger.LogInformation("Deleted product {ProductId}", id); + + return Result.Success(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting product {ProductId}", id); + return Result.Failure("An error occurred while deleting the product", "INTERNAL_ERROR"); + } + } + + private static string GetCacheKey(string id) => $"product:{id}"; +} + +// Supporting types +public record CreateProductRequest(string Name, string Sku, decimal Price, int CategoryId); +public record UpdateProductRequest(string? Name = null, decimal? Price = null, int? CategoryId = null); +public record ProductSearchRequest( + string? SearchTerm = null, + int? CategoryId = null, + decimal? MinPrice = null, + decimal? MaxPrice = null, + int? Page = null, + int? PageSize = null); + +public class PagedResult +{ + public IReadOnlyList Items { get; init; } = Array.Empty(); + public int TotalCount { get; init; } + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalPages { get; init; } + public bool HasNextPage => Page < TotalPages; + public bool HasPreviousPage => Page > 1; +} + +public class Product +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Sku { get; set; } = string.Empty; + public decimal Price { get; set; } + public int CategoryId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } +} + +// Validators using FluentValidation +public class CreateProductRequestValidator : AbstractValidator +{ + public CreateProductRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(200).WithMessage("Name must not exceed 200 characters"); + + RuleFor(x => x.Sku) + .NotEmpty().WithMessage("SKU is required") + .MaximumLength(50).WithMessage("SKU must not exceed 50 characters") + .Matches(@"^[A-Z0-9\-]+$").WithMessage("SKU must contain only uppercase letters, numbers, and hyphens"); + + RuleFor(x => x.Price) + .GreaterThan(0).WithMessage("Price must be greater than 0"); + + RuleFor(x => x.CategoryId) + .GreaterThan(0).WithMessage("Category is required"); + } +} diff --git a/plugins/dotnet-contribution/skills/dotnet-backend-patterns/references/dapper-patterns.md b/plugins/dotnet-contribution/skills/dotnet-backend-patterns/references/dapper-patterns.md new file mode 100644 index 0000000..2705859 --- /dev/null +++ b/plugins/dotnet-contribution/skills/dotnet-backend-patterns/references/dapper-patterns.md @@ -0,0 +1,544 @@ +# 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(sp => +{ + var connectionString = sp.GetRequiredService() + .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 GetByIdAsync(string id, CancellationToken ct) + { + // Connection opens automatically, closes on dispose + using var connection = _factory.CreateConnection(); + + return await connection.QueryFirstOrDefaultAsync( + 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( + "SELECT * FROM Products WHERE Id = @Id", + new { Id = id }); + +// SELECT multiple +var products = await connection.QueryAsync( + "SELECT * FROM Products WHERE CategoryId = @CategoryId", + new { CategoryId = categoryId }); + +// INSERT with identity return +var newId = await connection.QuerySingleAsync( + """ + 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( + """ + 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> 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(sql.ToString(), parameters); + return results.ToList(); +} +``` + +### 5. Multi-Mapping (Joins) + +```csharp +// One-to-One mapping +public async Task 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( + 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 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(); + + using var connection = _factory.CreateConnection(); + + await connection.QueryAsync( + sql, + (order, item, product) => + { + if (!orderDictionary.TryGetValue(order.Id, out var existingOrder)) + { + existingOrder = order; + existingOrder.Items = new List(); + 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 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(); + var products = (await multi.ReadAsync()).ToList(); + + return (products, totalCount); +} +``` + +## Advanced Patterns + +### 7. Table-Valued Parameters (Bulk Operations) + +```csharp +// SQL Server TVP for bulk operations +public async Task> GetByIdsAsync(IEnumerable 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( + "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> GetTopProductsAsync(int categoryId, int count) +{ + using var connection = _factory.CreateConnection(); + + var results = await connection.QueryAsync( + "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("OrderId"); + var confirmationCode = parameters.Get("ConfirmationCode"); + + return (order, confirmationCode); +} +``` + +### 9. Transactions + +```csharp +public async Task CreateOrderWithItemsAsync(Order order, List items) +{ + using var connection = _factory.CreateConnection(); + await connection.OpenAsync(); + + using var transaction = await connection.BeginTransactionAsync(); + + try + { + // Insert order + order.Id = await connection.QuerySingleAsync( + """ + 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 : SqlMapper.TypeHandler +{ + public override T Parse(object value) + { + if (value is string json) + { + return JsonSerializer.Deserialize(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()); + +// Now you can query directly +var product = await connection.QueryFirstAsync( + "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( + 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(sql); // Returns list + +// Unbuffered - streams results (lower memory for large result sets) +var products = await connection.QueryUnbufferedAsync(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 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 GetByIdAsync(TId id, CancellationToken ct = default) + { + var sql = $"SELECT * FROM {TableName} WHERE Id = @Id"; + + using var connection = ConnectionFactory.CreateConnection(); + return await connection.QueryFirstOrDefaultAsync( + new CommandDefinition(sql, new { Id = id }, cancellationToken: ct)); + } + + protected async Task> GetAllAsync(CancellationToken ct = default) + { + var sql = $"SELECT * FROM {TableName}"; + + using var connection = ConnectionFactory.CreateConnection(); + var results = await connection.QueryAsync( + new CommandDefinition(sql, cancellationToken: ct)); + + return results.ToList(); + } + + protected async Task 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(sql, new { Name = userInput }); + +// ❌ Bad - Not disposing connection +var connection = new SqlConnection(connectionString); +var result = await connection.QueryAsync(sql); +// Connection leak! + +// ✅ Good - Using statement +using var connection = new SqlConnection(connectionString); +var result = await connection.QueryAsync(sql); + +// ❌ Bad - Opening connection manually when not needed +await connection.OpenAsync(); // Dapper does this automatically +var result = await connection.QueryAsync(sql); + +// ✅ Good - Let Dapper manage connection +var result = await connection.QueryAsync(sql); +``` diff --git a/plugins/dotnet-contribution/skills/dotnet-backend-patterns/references/ef-core-best-practices.md b/plugins/dotnet-contribution/skills/dotnet-backend-patterns/references/ef-core-best-practices.md new file mode 100644 index 0000000..dce273b --- /dev/null +++ b/plugins/dotnet-contribution/skills/dotnet-backend-patterns/references/ef-core-best-practices.md @@ -0,0 +1,355 @@ +# 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> GetByIdQuery = + EF.CompileAsyncQuery((AppDbContext ctx, string id) => + ctx.Products.AsNoTracking().FirstOrDefault(p => p.Id == id)); + + private static readonly Func> GetByCategoryQuery = + EF.CompileAsyncQuery((AppDbContext ctx, int categoryId) => + ctx.Products.AsNoTracking().Where(p => p.CategoryId == categoryId)); + + public Task GetByIdAsync(string id, CancellationToken ct) + => GetByIdQuery(_context, id); + + public IAsyncEnumerable 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(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(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 +{ + public void Configure(EntityTypeBuilder 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(); +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(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); + } + } + }); +}); +```