mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 09:37:15 +00:00
feat: add .NET backend development plugin (#157)
Co-authored-by: Martineto21 <ramac21@gmail.com>
This commit is contained in:
committed by
GitHub
parent
f95810b340
commit
c81daa055d
127
plugins/dotnet-contribution/README.md
Normal file
127
plugins/dotnet-contribution/README.md
Normal file
@@ -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.
|
||||||
175
plugins/dotnet-contribution/agents/dotnet-architect.md
Normal file
175
plugins/dotnet-contribution/agents/dotnet-architect.md
Normal file
@@ -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<T>, Memory<T>, 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<ProductService> logger) : IProductService
|
||||||
|
{
|
||||||
|
public async Task<Result<Product>> GetByIdAsync(
|
||||||
|
string id,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||||
|
|
||||||
|
var cached = await cache.GetAsync<Product>($"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>("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"
|
||||||
|
};
|
||||||
|
```
|
||||||
@@ -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<IProductService, ProductService>();
|
||||||
|
services.AddScoped<IOrderService, OrderService>();
|
||||||
|
|
||||||
|
// Singleton: One instance for app lifetime
|
||||||
|
services.AddSingleton<ICacheService, RedisCacheService>();
|
||||||
|
services.AddSingleton<IConnectionMultiplexer>(_ =>
|
||||||
|
ConnectionMultiplexer.Connect(configuration["Redis:Connection"]!));
|
||||||
|
|
||||||
|
// Transient: New instance every time
|
||||||
|
services.AddTransient<IValidator<CreateOrderRequest>, CreateOrderValidator>();
|
||||||
|
|
||||||
|
// Options pattern for configuration
|
||||||
|
services.Configure<CatalogOptions>(configuration.GetSection("Catalog"));
|
||||||
|
services.Configure<RedisOptions>(configuration.GetSection("Redis"));
|
||||||
|
|
||||||
|
// Factory pattern for conditional creation
|
||||||
|
services.AddScoped<IPriceCalculator>(sp =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<IOptions<PricingOptions>>().Value;
|
||||||
|
return options.UseNewEngine
|
||||||
|
? sp.GetRequiredService<NewPriceCalculator>()
|
||||||
|
: sp.GetRequiredService<LegacyPriceCalculator>();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyed services (.NET 8+)
|
||||||
|
services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("stripe");
|
||||||
|
services.AddKeyedScoped<IPaymentProcessor, PayPalProcessor>("paypal");
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage with keyed services
|
||||||
|
public class CheckoutService
|
||||||
|
{
|
||||||
|
public CheckoutService(
|
||||||
|
[FromKeyedServices("stripe")] IPaymentProcessor stripeProcessor)
|
||||||
|
{
|
||||||
|
_processor = stripeProcessor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Async/Await Patterns
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ✅ CORRECT: Async all the way down
|
||||||
|
public async Task<Product> GetProductAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _repository.GetByIdAsync(id, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ CORRECT: Parallel execution with WhenAll
|
||||||
|
public async Task<(Stock, Price)> GetStockAndPriceAsync(
|
||||||
|
string productId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var stockTask = _stockService.GetAsync(productId, ct);
|
||||||
|
var priceTask = _priceService.GetAsync(productId, ct);
|
||||||
|
|
||||||
|
await Task.WhenAll(stockTask, priceTask);
|
||||||
|
|
||||||
|
return (await stockTask, await priceTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ CORRECT: ConfigureAwait in libraries
|
||||||
|
public async Task<T> LibraryMethodAsync<T>(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var result = await _httpClient.GetAsync(url, ct).ConfigureAwait(false);
|
||||||
|
return await result.Content.ReadFromJsonAsync<T>(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ CORRECT: ValueTask for hot paths with caching
|
||||||
|
public ValueTask<Product?> GetCachedProductAsync(string id)
|
||||||
|
{
|
||||||
|
if (_cache.TryGetValue(id, out Product? product))
|
||||||
|
return ValueTask.FromResult(product);
|
||||||
|
|
||||||
|
return new ValueTask<Product?>(GetFromDatabaseAsync(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ WRONG: Blocking on async (deadlock risk)
|
||||||
|
var result = GetProductAsync(id).Result; // NEVER do this
|
||||||
|
var result2 = GetProductAsync(id).GetAwaiter().GetResult(); // Also bad
|
||||||
|
|
||||||
|
// ❌ WRONG: async void (except event handlers)
|
||||||
|
public async void ProcessOrder() { } // Exceptions are lost
|
||||||
|
|
||||||
|
// ❌ WRONG: Unnecessary Task.Run for already async code
|
||||||
|
await Task.Run(async () => await GetDataAsync()); // Wastes thread
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Configuration with IOptions
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Configuration classes
|
||||||
|
public class CatalogOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Catalog";
|
||||||
|
|
||||||
|
public int DefaultPageSize { get; set; } = 50;
|
||||||
|
public int MaxPageSize { get; set; } = 200;
|
||||||
|
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(15);
|
||||||
|
public bool EnableEnrichment { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RedisOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Redis";
|
||||||
|
|
||||||
|
public string Connection { get; set; } = "localhost:6379";
|
||||||
|
public string KeyPrefix { get; set; } = "mcp:";
|
||||||
|
public int Database { get; set; } = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// appsettings.json
|
||||||
|
{
|
||||||
|
"Catalog": {
|
||||||
|
"DefaultPageSize": 50,
|
||||||
|
"MaxPageSize": 200,
|
||||||
|
"CacheDuration": "00:15:00",
|
||||||
|
"EnableEnrichment": true
|
||||||
|
},
|
||||||
|
"Redis": {
|
||||||
|
"Connection": "localhost:6379",
|
||||||
|
"KeyPrefix": "mcp:",
|
||||||
|
"Database": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registration
|
||||||
|
services.Configure<CatalogOptions>(configuration.GetSection(CatalogOptions.SectionName));
|
||||||
|
services.Configure<RedisOptions>(configuration.GetSection(RedisOptions.SectionName));
|
||||||
|
|
||||||
|
// Usage with IOptions (singleton, read once at startup)
|
||||||
|
public class CatalogService
|
||||||
|
{
|
||||||
|
private readonly CatalogOptions _options;
|
||||||
|
|
||||||
|
public CatalogService(IOptions<CatalogOptions> options)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage with IOptionsSnapshot (scoped, re-reads on each request)
|
||||||
|
public class DynamicService
|
||||||
|
{
|
||||||
|
private readonly CatalogOptions _options;
|
||||||
|
|
||||||
|
public DynamicService(IOptionsSnapshot<CatalogOptions> options)
|
||||||
|
{
|
||||||
|
_options = options.Value; // Fresh value per request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage with IOptionsMonitor (singleton, notified on changes)
|
||||||
|
public class MonitoredService
|
||||||
|
{
|
||||||
|
private CatalogOptions _options;
|
||||||
|
|
||||||
|
public MonitoredService(IOptionsMonitor<CatalogOptions> monitor)
|
||||||
|
{
|
||||||
|
_options = monitor.CurrentValue;
|
||||||
|
monitor.OnChange(newOptions => _options = newOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Result Pattern (Avoiding Exceptions for Flow Control)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Generic Result type
|
||||||
|
public class Result<T>
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; }
|
||||||
|
public T? Value { get; }
|
||||||
|
public string? Error { get; }
|
||||||
|
public string? ErrorCode { get; }
|
||||||
|
|
||||||
|
private Result(bool isSuccess, T? value, string? error, string? errorCode)
|
||||||
|
{
|
||||||
|
IsSuccess = isSuccess;
|
||||||
|
Value = value;
|
||||||
|
Error = error;
|
||||||
|
ErrorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<T> Success(T value) => new(true, value, null, null);
|
||||||
|
public static Result<T> Failure(string error, string? code = null) => new(false, default, error, code);
|
||||||
|
|
||||||
|
public Result<TNew> Map<TNew>(Func<T, TNew> mapper) =>
|
||||||
|
IsSuccess ? Result<TNew>.Success(mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
|
||||||
|
|
||||||
|
public async Task<Result<TNew>> MapAsync<TNew>(Func<T, Task<TNew>> mapper) =>
|
||||||
|
IsSuccess ? Result<TNew>.Success(await mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in service
|
||||||
|
public async Task<Result<Order>> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Validation
|
||||||
|
var validation = await _validator.ValidateAsync(request, ct);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
return Result<Order>.Failure(
|
||||||
|
validation.Errors.First().ErrorMessage,
|
||||||
|
"VALIDATION_ERROR");
|
||||||
|
|
||||||
|
// Business rule check
|
||||||
|
var stock = await _stockService.CheckAsync(request.ProductId, request.Quantity, ct);
|
||||||
|
if (!stock.IsAvailable)
|
||||||
|
return Result<Order>.Failure(
|
||||||
|
$"Insufficient stock: {stock.Available} available, {request.Quantity} requested",
|
||||||
|
"INSUFFICIENT_STOCK");
|
||||||
|
|
||||||
|
// Create order
|
||||||
|
var order = await _repository.CreateAsync(request.ToEntity(), ct);
|
||||||
|
|
||||||
|
return Result<Order>.Success(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in controller/endpoint
|
||||||
|
app.MapPost("/orders", async (
|
||||||
|
CreateOrderRequest request,
|
||||||
|
IOrderService orderService,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var result = await orderService.CreateOrderAsync(request, ct);
|
||||||
|
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Created($"/orders/{result.Value!.Id}", result.Value)
|
||||||
|
: Results.BadRequest(new { error = result.Error, code = result.ErrorCode });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Access Patterns
|
||||||
|
|
||||||
|
### Entity Framework Core
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// DbContext configuration
|
||||||
|
public class AppDbContext : DbContext
|
||||||
|
{
|
||||||
|
public DbSet<Product> Products => Set<Product>();
|
||||||
|
public DbSet<Order> Orders => Set<Order>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
// Apply all configurations from assembly
|
||||||
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
|
||||||
|
|
||||||
|
// Global query filters
|
||||||
|
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity configuration
|
||||||
|
public class ProductConfiguration : IEntityTypeConfiguration<Product>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Product> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("Products");
|
||||||
|
|
||||||
|
builder.HasKey(p => p.Id);
|
||||||
|
builder.Property(p => p.Id).HasMaxLength(40);
|
||||||
|
builder.Property(p => p.Name).HasMaxLength(200).IsRequired();
|
||||||
|
builder.Property(p => p.Price).HasPrecision(18, 2);
|
||||||
|
|
||||||
|
builder.HasIndex(p => p.Sku).IsUnique();
|
||||||
|
builder.HasIndex(p => new { p.CategoryId, p.Name });
|
||||||
|
|
||||||
|
builder.HasMany(p => p.OrderItems)
|
||||||
|
.WithOne(oi => oi.Product)
|
||||||
|
.HasForeignKey(oi => oi.ProductId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository with EF Core
|
||||||
|
public class ProductRepository : IProductRepository
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _context;
|
||||||
|
|
||||||
|
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _context.Products
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == id, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Product>> SearchAsync(
|
||||||
|
ProductSearchCriteria criteria,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var query = _context.Products.AsNoTracking();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(criteria.SearchTerm))
|
||||||
|
query = query.Where(p => EF.Functions.Like(p.Name, $"%{criteria.SearchTerm}%"));
|
||||||
|
|
||||||
|
if (criteria.CategoryId.HasValue)
|
||||||
|
query = query.Where(p => p.CategoryId == criteria.CategoryId);
|
||||||
|
|
||||||
|
if (criteria.MinPrice.HasValue)
|
||||||
|
query = query.Where(p => p.Price >= criteria.MinPrice);
|
||||||
|
|
||||||
|
if (criteria.MaxPrice.HasValue)
|
||||||
|
query = query.Where(p => p.Price <= criteria.MaxPrice);
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderBy(p => p.Name)
|
||||||
|
.Skip((criteria.Page - 1) * criteria.PageSize)
|
||||||
|
.Take(criteria.PageSize)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dapper for Performance
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class DapperProductRepository : IProductRepository
|
||||||
|
{
|
||||||
|
private readonly IDbConnection _connection;
|
||||||
|
|
||||||
|
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt
|
||||||
|
FROM Products
|
||||||
|
WHERE Id = @Id AND IsDeleted = 0
|
||||||
|
""";
|
||||||
|
|
||||||
|
return await _connection.QueryFirstOrDefaultAsync<Product>(
|
||||||
|
new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Product>> SearchAsync(
|
||||||
|
ProductSearchCriteria criteria,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sql = new StringBuilder("""
|
||||||
|
SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt
|
||||||
|
FROM Products
|
||||||
|
WHERE IsDeleted = 0
|
||||||
|
""");
|
||||||
|
|
||||||
|
var parameters = new DynamicParameters();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(criteria.SearchTerm))
|
||||||
|
{
|
||||||
|
sql.Append(" AND Name LIKE @SearchTerm");
|
||||||
|
parameters.Add("SearchTerm", $"%{criteria.SearchTerm}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.CategoryId.HasValue)
|
||||||
|
{
|
||||||
|
sql.Append(" AND CategoryId = @CategoryId");
|
||||||
|
parameters.Add("CategoryId", criteria.CategoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.MinPrice.HasValue)
|
||||||
|
{
|
||||||
|
sql.Append(" AND Price >= @MinPrice");
|
||||||
|
parameters.Add("MinPrice", criteria.MinPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.MaxPrice.HasValue)
|
||||||
|
{
|
||||||
|
sql.Append(" AND Price <= @MaxPrice");
|
||||||
|
parameters.Add("MaxPrice", criteria.MaxPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql.Append(" ORDER BY Name OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY");
|
||||||
|
parameters.Add("Offset", (criteria.Page - 1) * criteria.PageSize);
|
||||||
|
parameters.Add("PageSize", criteria.PageSize);
|
||||||
|
|
||||||
|
var results = await _connection.QueryAsync<Product>(
|
||||||
|
new CommandDefinition(sql.ToString(), parameters, cancellationToken: ct));
|
||||||
|
|
||||||
|
return results.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-mapping for related data
|
||||||
|
public async Task<Order?> GetOrderWithItemsAsync(int orderId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT o.*, oi.*, p.*
|
||||||
|
FROM Orders o
|
||||||
|
LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
|
||||||
|
LEFT JOIN Products p ON oi.ProductId = p.Id
|
||||||
|
WHERE o.Id = @OrderId
|
||||||
|
""";
|
||||||
|
|
||||||
|
var orderDictionary = new Dictionary<int, Order>();
|
||||||
|
|
||||||
|
await _connection.QueryAsync<Order, OrderItem, Product, Order>(
|
||||||
|
new CommandDefinition(sql, new { OrderId = orderId }, cancellationToken: ct),
|
||||||
|
(order, item, product) =>
|
||||||
|
{
|
||||||
|
if (!orderDictionary.TryGetValue(order.Id, out var existingOrder))
|
||||||
|
{
|
||||||
|
existingOrder = order;
|
||||||
|
existingOrder.Items = new List<OrderItem>();
|
||||||
|
orderDictionary.Add(order.Id, existingOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item != null)
|
||||||
|
{
|
||||||
|
item.Product = product;
|
||||||
|
existingOrder.Items.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingOrder;
|
||||||
|
},
|
||||||
|
splitOn: "Id,Id");
|
||||||
|
|
||||||
|
return orderDictionary.Values.FirstOrDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caching Patterns
|
||||||
|
|
||||||
|
### Multi-Level Cache with Redis
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class CachedProductService : IProductService
|
||||||
|
{
|
||||||
|
private readonly IProductRepository _repository;
|
||||||
|
private readonly IMemoryCache _memoryCache;
|
||||||
|
private readonly IDistributedCache _distributedCache;
|
||||||
|
private readonly ILogger<CachedProductService> _logger;
|
||||||
|
|
||||||
|
private static readonly TimeSpan MemoryCacheDuration = TimeSpan.FromMinutes(1);
|
||||||
|
private static readonly TimeSpan DistributedCacheDuration = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var cacheKey = $"product:{id}";
|
||||||
|
|
||||||
|
// L1: Memory cache (in-process, fastest)
|
||||||
|
if (_memoryCache.TryGetValue(cacheKey, out Product? cached))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("L1 cache hit for {CacheKey}", cacheKey);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// L2: Distributed cache (Redis)
|
||||||
|
var distributed = await _distributedCache.GetStringAsync(cacheKey, ct);
|
||||||
|
if (distributed != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("L2 cache hit for {CacheKey}", cacheKey);
|
||||||
|
var product = JsonSerializer.Deserialize<Product>(distributed);
|
||||||
|
|
||||||
|
// Populate L1
|
||||||
|
_memoryCache.Set(cacheKey, product, MemoryCacheDuration);
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
// L3: Database
|
||||||
|
_logger.LogDebug("Cache miss for {CacheKey}, fetching from database", cacheKey);
|
||||||
|
var fromDb = await _repository.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
if (fromDb != null)
|
||||||
|
{
|
||||||
|
var serialized = JsonSerializer.Serialize(fromDb);
|
||||||
|
|
||||||
|
// Populate both caches
|
||||||
|
await _distributedCache.SetStringAsync(
|
||||||
|
cacheKey,
|
||||||
|
serialized,
|
||||||
|
new DistributedCacheEntryOptions
|
||||||
|
{
|
||||||
|
AbsoluteExpirationRelativeToNow = DistributedCacheDuration
|
||||||
|
},
|
||||||
|
ct);
|
||||||
|
|
||||||
|
_memoryCache.Set(cacheKey, fromDb, MemoryCacheDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fromDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvalidateAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var cacheKey = $"product:{id}";
|
||||||
|
|
||||||
|
_memoryCache.Remove(cacheKey);
|
||||||
|
await _distributedCache.RemoveAsync(cacheKey, ct);
|
||||||
|
|
||||||
|
_logger.LogInformation("Invalidated cache for {CacheKey}", cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale-while-revalidate pattern
|
||||||
|
public class StaleWhileRevalidateCache<T>
|
||||||
|
{
|
||||||
|
private readonly IDistributedCache _cache;
|
||||||
|
private readonly TimeSpan _freshDuration;
|
||||||
|
private readonly TimeSpan _staleDuration;
|
||||||
|
|
||||||
|
public async Task<T?> GetOrCreateAsync(
|
||||||
|
string key,
|
||||||
|
Func<CancellationToken, Task<T>> factory,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var cached = await _cache.GetStringAsync(key, ct);
|
||||||
|
|
||||||
|
if (cached != null)
|
||||||
|
{
|
||||||
|
var entry = JsonSerializer.Deserialize<CacheEntry<T>>(cached)!;
|
||||||
|
|
||||||
|
if (entry.IsStale && !entry.IsExpired)
|
||||||
|
{
|
||||||
|
// Return stale data immediately, refresh in background
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var fresh = await factory(CancellationToken.None);
|
||||||
|
await SetAsync(key, fresh, CancellationToken.None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.IsExpired)
|
||||||
|
return entry.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss or expired
|
||||||
|
var value = await factory(ct);
|
||||||
|
await SetAsync(key, value, ct);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CacheEntry<TValue>(TValue Value, DateTime CreatedAt)
|
||||||
|
{
|
||||||
|
public bool IsStale => DateTime.UtcNow - CreatedAt > _freshDuration;
|
||||||
|
public bool IsExpired => DateTime.UtcNow - CreatedAt > _staleDuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Patterns
|
||||||
|
|
||||||
|
### Unit Tests with xUnit and Moq
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class OrderServiceTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IOrderRepository> _mockRepository;
|
||||||
|
private readonly Mock<IStockService> _mockStockService;
|
||||||
|
private readonly Mock<IValidator<CreateOrderRequest>> _mockValidator;
|
||||||
|
private readonly OrderService _sut; // System Under Test
|
||||||
|
|
||||||
|
public OrderServiceTests()
|
||||||
|
{
|
||||||
|
_mockRepository = new Mock<IOrderRepository>();
|
||||||
|
_mockStockService = new Mock<IStockService>();
|
||||||
|
_mockValidator = new Mock<IValidator<CreateOrderRequest>>();
|
||||||
|
|
||||||
|
// Default: validation passes
|
||||||
|
_mockValidator
|
||||||
|
.Setup(v => v.ValidateAsync(It.IsAny<CreateOrderRequest>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new ValidationResult());
|
||||||
|
|
||||||
|
_sut = new OrderService(
|
||||||
|
_mockRepository.Object,
|
||||||
|
_mockStockService.Object,
|
||||||
|
_mockValidator.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateOrderAsync_WithValidRequest_ReturnsSuccess()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var request = new CreateOrderRequest
|
||||||
|
{
|
||||||
|
ProductId = "PROD-001",
|
||||||
|
Quantity = 5,
|
||||||
|
CustomerOrderCode = "ORD-2024-001"
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockStockService
|
||||||
|
.Setup(s => s.CheckAsync("PROD-001", 5, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new StockResult { IsAvailable = true, Available = 10 });
|
||||||
|
|
||||||
|
_mockRepository
|
||||||
|
.Setup(r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new Order { Id = 1, CustomerOrderCode = "ORD-2024-001" });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.CreateOrderAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(result.Value);
|
||||||
|
Assert.Equal(1, result.Value.Id);
|
||||||
|
|
||||||
|
_mockRepository.Verify(
|
||||||
|
r => r.CreateAsync(It.Is<Order>(o => o.CustomerOrderCode == "ORD-2024-001"),
|
||||||
|
It.IsAny<CancellationToken>()),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateOrderAsync_WithInsufficientStock_ReturnsFailure()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = 100 };
|
||||||
|
|
||||||
|
_mockStockService
|
||||||
|
.Setup(s => s.CheckAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new StockResult { IsAvailable = false, Available = 5 });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.CreateOrderAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal("INSUFFICIENT_STOCK", result.ErrorCode);
|
||||||
|
Assert.Contains("5 available", result.Error);
|
||||||
|
|
||||||
|
_mockRepository.Verify(
|
||||||
|
r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
[InlineData(-100)]
|
||||||
|
public async Task CreateOrderAsync_WithInvalidQuantity_ReturnsValidationError(int quantity)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = quantity };
|
||||||
|
|
||||||
|
_mockValidator
|
||||||
|
.Setup(v => v.ValidateAsync(request, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new ValidationResult(new[]
|
||||||
|
{
|
||||||
|
new ValidationFailure("Quantity", "Quantity must be greater than 0")
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.CreateOrderAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal("VALIDATION_ERROR", result.ErrorCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests with WebApplicationFactory
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||||
|
{
|
||||||
|
private readonly WebApplicationFactory<Program> _factory;
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public ProductsApiTests(WebApplicationFactory<Program> factory)
|
||||||
|
{
|
||||||
|
_factory = factory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
// Replace real database with in-memory
|
||||||
|
services.RemoveAll<DbContextOptions<AppDbContext>>();
|
||||||
|
services.AddDbContext<AppDbContext>(options =>
|
||||||
|
options.UseInMemoryDatabase("TestDb"));
|
||||||
|
|
||||||
|
// Replace Redis with memory cache
|
||||||
|
services.RemoveAll<IDistributedCache>();
|
||||||
|
services.AddDistributedMemoryCache();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
_client = _factory.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetProduct_WithValidId_ReturnsProduct()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
context.Products.Add(new Product
|
||||||
|
{
|
||||||
|
Id = "TEST-001",
|
||||||
|
Name = "Test Product",
|
||||||
|
Price = 99.99m
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await _client.GetAsync("/api/products/TEST-001");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var product = await response.Content.ReadFromJsonAsync<Product>();
|
||||||
|
Assert.Equal("Test Product", product!.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetProduct_WithInvalidId_Returns404()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var response = await _client.GetAsync("/api/products/NONEXISTENT");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### DO
|
||||||
|
1. **Use async/await** all the way through the call stack
|
||||||
|
2. **Inject dependencies** through constructor injection
|
||||||
|
3. **Use IOptions<T>** for typed configuration
|
||||||
|
4. **Return Result types** instead of throwing exceptions for business logic
|
||||||
|
5. **Use CancellationToken** in all async methods
|
||||||
|
6. **Prefer Dapper** for read-heavy, performance-critical queries
|
||||||
|
7. **Use EF Core** for complex domain models with change tracking
|
||||||
|
8. **Cache aggressively** with proper invalidation strategies
|
||||||
|
9. **Write unit tests** for business logic, integration tests for APIs
|
||||||
|
10. **Use record types** for DTOs and immutable data
|
||||||
|
|
||||||
|
### DON'T
|
||||||
|
1. **Don't block on async** with `.Result` or `.Wait()`
|
||||||
|
2. **Don't use async void** except for event handlers
|
||||||
|
3. **Don't catch generic Exception** without re-throwing or logging
|
||||||
|
4. **Don't hardcode** configuration values
|
||||||
|
5. **Don't expose EF entities** directly in APIs (use DTOs)
|
||||||
|
6. **Don't forget** `AsNoTracking()` for read-only queries
|
||||||
|
7. **Don't ignore** CancellationToken parameters
|
||||||
|
8. **Don't create** `new HttpClient()` manually (use IHttpClientFactory)
|
||||||
|
9. **Don't mix** sync and async code unnecessarily
|
||||||
|
10. **Don't skip** validation at API boundaries
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- **N+1 Queries**: Use `.Include()` or explicit joins
|
||||||
|
- **Memory Leaks**: Dispose IDisposable resources, use `using`
|
||||||
|
- **Deadlocks**: Don't mix sync and async, use ConfigureAwait(false) in libraries
|
||||||
|
- **Over-fetching**: Select only needed columns, use projections
|
||||||
|
- **Missing Indexes**: Check query plans, add indexes for common filters
|
||||||
|
- **Timeout Issues**: Configure appropriate timeouts for HTTP clients
|
||||||
|
- **Cache Stampede**: Use distributed locks for cache population
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- **assets/service-template.cs**: Complete service implementation template
|
||||||
|
- **assets/repository-template.cs**: Repository pattern implementation
|
||||||
|
- **references/ef-core-best-practices.md**: EF Core optimization guide
|
||||||
|
- **references/dapper-patterns.md**: Advanced Dapper usage patterns
|
||||||
@@ -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<Product?> GetByIdAsync(string id, CancellationToken ct = default);
|
||||||
|
Task<Product?> GetBySkuAsync(string sku, CancellationToken ct = default);
|
||||||
|
Task<(IReadOnlyList<Product> Items, int TotalCount)> SearchAsync(ProductSearchRequest request, CancellationToken ct = default);
|
||||||
|
Task<Product> CreateAsync(Product product, CancellationToken ct = default);
|
||||||
|
Task<Product> UpdateAsync(Product product, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(string id, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Product>> GetByIdsAsync(IEnumerable<string> ids, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Dapper Implementation (High Performance)
|
||||||
|
|
||||||
|
public class DapperProductRepository : IProductRepository
|
||||||
|
{
|
||||||
|
private readonly IDbConnection _connection;
|
||||||
|
private readonly ILogger<DapperProductRepository> _logger;
|
||||||
|
|
||||||
|
public DapperProductRepository(
|
||||||
|
IDbConnection connection,
|
||||||
|
ILogger<DapperProductRepository> logger)
|
||||||
|
{
|
||||||
|
_connection = connection;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Product?> 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<Product>(
|
||||||
|
new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Product?> 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<Product>(
|
||||||
|
new CommandDefinition(sql, new { Sku = sku }, cancellationToken: ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IReadOnlyList<Product> Items, int TotalCount)> SearchAsync(
|
||||||
|
ProductSearchRequest request,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var whereClauses = new List<string> { "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<int>();
|
||||||
|
var items = (await multi.ReadAsync<Product>()).ToList();
|
||||||
|
|
||||||
|
return (items, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Product> 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<Product>(
|
||||||
|
new CommandDefinition(sql, product, cancellationToken: ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Product> 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<Product>(
|
||||||
|
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<IReadOnlyList<Product>> GetByIdsAsync(
|
||||||
|
IEnumerable<string> ids,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var idList = ids.ToList();
|
||||||
|
if (idList.Count == 0)
|
||||||
|
return Array.Empty<Product>();
|
||||||
|
|
||||||
|
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<Product>(
|
||||||
|
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<EfCoreProductRepository> _logger;
|
||||||
|
|
||||||
|
public EfCoreProductRepository(
|
||||||
|
AppDbContext context,
|
||||||
|
ILogger<EfCoreProductRepository> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _context.Products
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == id, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Product?> GetBySkuAsync(string sku, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _context.Products
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(p => p.Sku == sku, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IReadOnlyList<Product> 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<Product> CreateAsync(Product product, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_context.Products.Add(product);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Product> 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<IReadOnlyList<Product>> GetByIdsAsync(
|
||||||
|
IEnumerable<string> ids,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var idList = ids.ToList();
|
||||||
|
if (idList.Count == 0)
|
||||||
|
return Array.Empty<Product>();
|
||||||
|
|
||||||
|
return await _context.Products
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(p => idList.Contains(p.Id))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region DbContext Configuration
|
||||||
|
|
||||||
|
public class AppDbContext : DbContext
|
||||||
|
{
|
||||||
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
|
public DbSet<Product> Products => Set<Product>();
|
||||||
|
public DbSet<Category> Categories => Set<Category>();
|
||||||
|
public DbSet<Order> Orders => Set<Order>();
|
||||||
|
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
// Apply all configurations from assembly
|
||||||
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
|
||||||
|
|
||||||
|
// Global query filter for soft delete
|
||||||
|
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit of Work pattern for coordinating multiple repositories
|
||||||
|
/// </summary>
|
||||||
|
public interface IUnitOfWork : IDisposable
|
||||||
|
{
|
||||||
|
IProductRepository Products { get; }
|
||||||
|
IOrderRepository Orders { get; }
|
||||||
|
Task<int> 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<int> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specification pattern for complex queries
|
||||||
|
/// </summary>
|
||||||
|
public interface ISpecification<T>
|
||||||
|
{
|
||||||
|
Expression<Func<T, bool>> Criteria { get; }
|
||||||
|
List<Expression<Func<T, object>>> Includes { get; }
|
||||||
|
List<string> IncludeStrings { get; }
|
||||||
|
Expression<Func<T, object>>? OrderBy { get; }
|
||||||
|
Expression<Func<T, object>>? OrderByDescending { get; }
|
||||||
|
int? Take { get; }
|
||||||
|
int? Skip { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class BaseSpecification<T> : ISpecification<T>
|
||||||
|
{
|
||||||
|
public Expression<Func<T, bool>> Criteria { get; private set; } = _ => true;
|
||||||
|
public List<Expression<Func<T, object>>> Includes { get; } = new();
|
||||||
|
public List<string> IncludeStrings { get; } = new();
|
||||||
|
public Expression<Func<T, object>>? OrderBy { get; private set; }
|
||||||
|
public Expression<Func<T, object>>? OrderByDescending { get; private set; }
|
||||||
|
public int? Take { get; private set; }
|
||||||
|
public int? Skip { get; private set; }
|
||||||
|
|
||||||
|
protected void AddCriteria(Expression<Func<T, bool>> criteria) => Criteria = criteria;
|
||||||
|
protected void AddInclude(Expression<Func<T, object>> include) => Includes.Add(include);
|
||||||
|
protected void AddInclude(string include) => IncludeStrings.Add(include);
|
||||||
|
protected void ApplyOrderBy(Expression<Func<T, object>> orderBy) => OrderBy = orderBy;
|
||||||
|
protected void ApplyOrderByDescending(Expression<Func<T, object>> orderBy) => OrderByDescending = orderBy;
|
||||||
|
protected void ApplyPaging(int skip, int take) { Skip = skip; Take = take; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example specification
|
||||||
|
public class ProductsByCategorySpec : BaseSpecification<Product>
|
||||||
|
{
|
||||||
|
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<Product> Products { get; set; } = new List<Product>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<OrderItem> Items { get; set; } = new List<OrderItem>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration options for the service
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generic result type for operations that can fail
|
||||||
|
/// </summary>
|
||||||
|
public class Result<T>
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; }
|
||||||
|
public T? Value { get; }
|
||||||
|
public string? Error { get; }
|
||||||
|
public string? ErrorCode { get; }
|
||||||
|
|
||||||
|
private Result(bool isSuccess, T? value, string? error, string? errorCode)
|
||||||
|
{
|
||||||
|
IsSuccess = isSuccess;
|
||||||
|
Value = value;
|
||||||
|
Error = error;
|
||||||
|
ErrorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<T> Success(T value) => new(true, value, null, null);
|
||||||
|
public static Result<T> Failure(string error, string? code = null) => new(false, default, error, code);
|
||||||
|
|
||||||
|
public Result<TNew> Map<TNew>(Func<T, TNew> mapper) =>
|
||||||
|
IsSuccess ? Result<TNew>.Success(mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service interface - define the contract
|
||||||
|
/// </summary>
|
||||||
|
public interface IProductService
|
||||||
|
{
|
||||||
|
Task<Result<Product>> GetByIdAsync(string id, CancellationToken ct = default);
|
||||||
|
Task<Result<PagedResult<Product>>> SearchAsync(ProductSearchRequest request, CancellationToken ct = default);
|
||||||
|
Task<Result<Product>> CreateAsync(CreateProductRequest request, CancellationToken ct = default);
|
||||||
|
Task<Result<Product>> UpdateAsync(string id, UpdateProductRequest request, CancellationToken ct = default);
|
||||||
|
Task<Result<bool>> DeleteAsync(string id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service implementation with full patterns
|
||||||
|
/// </summary>
|
||||||
|
public class ProductService : IProductService
|
||||||
|
{
|
||||||
|
private readonly IProductRepository _repository;
|
||||||
|
private readonly ICacheService _cache;
|
||||||
|
private readonly IValidator<CreateProductRequest> _createValidator;
|
||||||
|
private readonly IValidator<UpdateProductRequest> _updateValidator;
|
||||||
|
private readonly ILogger<ProductService> _logger;
|
||||||
|
private readonly ProductServiceOptions _options;
|
||||||
|
|
||||||
|
public ProductService(
|
||||||
|
IProductRepository repository,
|
||||||
|
ICacheService cache,
|
||||||
|
IValidator<CreateProductRequest> createValidator,
|
||||||
|
IValidator<UpdateProductRequest> updateValidator,
|
||||||
|
ILogger<ProductService> logger,
|
||||||
|
IOptions<ProductServiceOptions> 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<Result<Product>> GetByIdAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
|
return Result<Product>.Failure("Product ID is required", "INVALID_ID");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Try cache first
|
||||||
|
var cacheKey = GetCacheKey(id);
|
||||||
|
var cached = await _cache.GetAsync<Product>(cacheKey, ct);
|
||||||
|
|
||||||
|
if (cached != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Cache hit for product {ProductId}", id);
|
||||||
|
return Result<Product>.Success(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from repository
|
||||||
|
var product = await _repository.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
if (product == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Product not found: {ProductId}", id);
|
||||||
|
return Result<Product>.Failure($"Product '{id}' not found", "NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate cache
|
||||||
|
await _cache.SetAsync(cacheKey, product, _options.CacheDuration, ct);
|
||||||
|
|
||||||
|
return Result<Product>.Success(product);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error retrieving product {ProductId}", id);
|
||||||
|
return Result<Product>.Failure("An error occurred while retrieving the product", "INTERNAL_ERROR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<PagedResult<Product>>> 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<Product>
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
TotalCount = totalCount,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
TotalPages = (int)Math.Ceiling((double)totalCount / pageSize)
|
||||||
|
};
|
||||||
|
|
||||||
|
return Result<PagedResult<Product>>.Success(result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error searching products with request {@Request}", request);
|
||||||
|
return Result<PagedResult<Product>>.Failure("An error occurred while searching products", "INTERNAL_ERROR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<Product>> 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<Product>.Failure(errors, "VALIDATION_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check for duplicates
|
||||||
|
var existing = await _repository.GetBySkuAsync(request.Sku, ct);
|
||||||
|
if (existing != null)
|
||||||
|
return Result<Product>.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<Product>.Success(created);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error creating product with SKU {Sku}", request.Sku);
|
||||||
|
return Result<Product>.Failure("An error occurred while creating the product", "INTERNAL_ERROR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<Product>> UpdateAsync(
|
||||||
|
string id,
|
||||||
|
UpdateProductRequest request,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
|
return Result<Product>.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<Product>.Failure(errors, "VALIDATION_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Fetch existing
|
||||||
|
var existing = await _repository.GetByIdAsync(id, ct);
|
||||||
|
if (existing == null)
|
||||||
|
return Result<Product>.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<Product>.Success(updated);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error updating product {ProductId}", id);
|
||||||
|
return Result<Product>.Failure("An error occurred while updating the product", "INTERNAL_ERROR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<bool>> DeleteAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
|
return Result<bool>.Failure("Product ID is required", "INVALID_ID");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existing = await _repository.GetByIdAsync(id, ct);
|
||||||
|
if (existing == null)
|
||||||
|
return Result<bool>.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<bool>.Success(true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error deleting product {ProductId}", id);
|
||||||
|
return Result<bool>.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<T>
|
||||||
|
{
|
||||||
|
public IReadOnlyList<T> Items { get; init; } = Array.Empty<T>();
|
||||||
|
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<CreateProductRequest>
|
||||||
|
{
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IDbConnection>(sp =>
|
||||||
|
{
|
||||||
|
var connectionString = sp.GetRequiredService<IConfiguration>()
|
||||||
|
.GetConnectionString("Default");
|
||||||
|
return new SqlConnection(connectionString);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or use a factory for more control
|
||||||
|
public interface IDbConnectionFactory
|
||||||
|
{
|
||||||
|
IDbConnection CreateConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SqlConnectionFactory : IDbConnectionFactory
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
public SqlConnectionFactory(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_connectionString = configuration.GetConnectionString("Default")
|
||||||
|
?? throw new InvalidOperationException("Connection string not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDbConnection CreateConnection() => new SqlConnection(_connectionString);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Connection Lifecycle
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class ProductRepository
|
||||||
|
{
|
||||||
|
private readonly IDbConnectionFactory _factory;
|
||||||
|
|
||||||
|
public ProductRepository(IDbConnectionFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Connection opens automatically, closes on dispose
|
||||||
|
using var connection = _factory.CreateConnection();
|
||||||
|
|
||||||
|
return await connection.QueryFirstOrDefaultAsync<Product>(
|
||||||
|
new CommandDefinition(
|
||||||
|
"SELECT * FROM Products WHERE Id = @Id",
|
||||||
|
new { Id = id },
|
||||||
|
cancellationToken: ct));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Patterns
|
||||||
|
|
||||||
|
### 3. Basic CRUD Operations
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// SELECT single
|
||||||
|
var product = await connection.QueryFirstOrDefaultAsync<Product>(
|
||||||
|
"SELECT * FROM Products WHERE Id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
|
||||||
|
// SELECT multiple
|
||||||
|
var products = await connection.QueryAsync<Product>(
|
||||||
|
"SELECT * FROM Products WHERE CategoryId = @CategoryId",
|
||||||
|
new { CategoryId = categoryId });
|
||||||
|
|
||||||
|
// INSERT with identity return
|
||||||
|
var newId = await connection.QuerySingleAsync<int>(
|
||||||
|
"""
|
||||||
|
INSERT INTO Products (Name, Price, CategoryId)
|
||||||
|
VALUES (@Name, @Price, @CategoryId);
|
||||||
|
SELECT CAST(SCOPE_IDENTITY() AS INT);
|
||||||
|
""",
|
||||||
|
product);
|
||||||
|
|
||||||
|
// INSERT with OUTPUT clause (returns full entity)
|
||||||
|
var inserted = await connection.QuerySingleAsync<Product>(
|
||||||
|
"""
|
||||||
|
INSERT INTO Products (Name, Price, CategoryId)
|
||||||
|
OUTPUT INSERTED.*
|
||||||
|
VALUES (@Name, @Price, @CategoryId);
|
||||||
|
""",
|
||||||
|
product);
|
||||||
|
|
||||||
|
// UPDATE
|
||||||
|
var rowsAffected = await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE Products
|
||||||
|
SET Name = @Name, Price = @Price, UpdatedAt = @UpdatedAt
|
||||||
|
WHERE Id = @Id
|
||||||
|
""",
|
||||||
|
new { product.Id, product.Name, product.Price, UpdatedAt = DateTime.UtcNow });
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"DELETE FROM Products WHERE Id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Dynamic Query Building
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<IReadOnlyList<Product>> SearchAsync(ProductSearchCriteria criteria)
|
||||||
|
{
|
||||||
|
var sql = new StringBuilder("SELECT * FROM Products WHERE 1=1");
|
||||||
|
var parameters = new DynamicParameters();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(criteria.SearchTerm))
|
||||||
|
{
|
||||||
|
sql.Append(" AND (Name LIKE @SearchTerm OR Sku LIKE @SearchTerm)");
|
||||||
|
parameters.Add("SearchTerm", $"%{criteria.SearchTerm}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.CategoryId.HasValue)
|
||||||
|
{
|
||||||
|
sql.Append(" AND CategoryId = @CategoryId");
|
||||||
|
parameters.Add("CategoryId", criteria.CategoryId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.MinPrice.HasValue)
|
||||||
|
{
|
||||||
|
sql.Append(" AND Price >= @MinPrice");
|
||||||
|
parameters.Add("MinPrice", criteria.MinPrice.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.MaxPrice.HasValue)
|
||||||
|
{
|
||||||
|
sql.Append(" AND Price <= @MaxPrice");
|
||||||
|
parameters.Add("MaxPrice", criteria.MaxPrice.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
sql.Append(" ORDER BY Name");
|
||||||
|
sql.Append(" OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY");
|
||||||
|
parameters.Add("Offset", (criteria.Page - 1) * criteria.PageSize);
|
||||||
|
parameters.Add("PageSize", criteria.PageSize);
|
||||||
|
|
||||||
|
using var connection = _factory.CreateConnection();
|
||||||
|
var results = await connection.QueryAsync<Product>(sql.ToString(), parameters);
|
||||||
|
return results.ToList();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Multi-Mapping (Joins)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// One-to-One mapping
|
||||||
|
public async Task<Product?> GetProductWithCategoryAsync(string id)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT p.*, c.*
|
||||||
|
FROM Products p
|
||||||
|
INNER JOIN Categories c ON p.CategoryId = c.Id
|
||||||
|
WHERE p.Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
using var connection = _factory.CreateConnection();
|
||||||
|
|
||||||
|
var result = await connection.QueryAsync<Product, Category, Product>(
|
||||||
|
sql,
|
||||||
|
(product, category) =>
|
||||||
|
{
|
||||||
|
product.Category = category;
|
||||||
|
return product;
|
||||||
|
},
|
||||||
|
new { Id = id },
|
||||||
|
splitOn: "Id"); // Column where split occurs
|
||||||
|
|
||||||
|
return result.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-to-Many mapping
|
||||||
|
public async Task<Order?> GetOrderWithItemsAsync(int orderId)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT o.*, oi.*, p.*
|
||||||
|
FROM Orders o
|
||||||
|
LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
|
||||||
|
LEFT JOIN Products p ON oi.ProductId = p.Id
|
||||||
|
WHERE o.Id = @OrderId
|
||||||
|
""";
|
||||||
|
|
||||||
|
var orderDictionary = new Dictionary<int, Order>();
|
||||||
|
|
||||||
|
using var connection = _factory.CreateConnection();
|
||||||
|
|
||||||
|
await connection.QueryAsync<Order, OrderItem, Product, Order>(
|
||||||
|
sql,
|
||||||
|
(order, item, product) =>
|
||||||
|
{
|
||||||
|
if (!orderDictionary.TryGetValue(order.Id, out var existingOrder))
|
||||||
|
{
|
||||||
|
existingOrder = order;
|
||||||
|
existingOrder.Items = new List<OrderItem>();
|
||||||
|
orderDictionary.Add(order.Id, existingOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item != null)
|
||||||
|
{
|
||||||
|
item.Product = product;
|
||||||
|
existingOrder.Items.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingOrder;
|
||||||
|
},
|
||||||
|
new { OrderId = orderId },
|
||||||
|
splitOn: "Id,Id");
|
||||||
|
|
||||||
|
return orderDictionary.Values.FirstOrDefault();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Multiple Result Sets
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<(IReadOnlyList<Product> Products, int TotalCount)> SearchWithCountAsync(
|
||||||
|
ProductSearchCriteria criteria)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
-- First result set: count
|
||||||
|
SELECT COUNT(*) FROM Products WHERE CategoryId = @CategoryId;
|
||||||
|
|
||||||
|
-- Second result set: data
|
||||||
|
SELECT * FROM Products
|
||||||
|
WHERE CategoryId = @CategoryId
|
||||||
|
ORDER BY Name
|
||||||
|
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;
|
||||||
|
""";
|
||||||
|
|
||||||
|
using var connection = _factory.CreateConnection();
|
||||||
|
using var multi = await connection.QueryMultipleAsync(sql, new
|
||||||
|
{
|
||||||
|
CategoryId = criteria.CategoryId,
|
||||||
|
Offset = (criteria.Page - 1) * criteria.PageSize,
|
||||||
|
PageSize = criteria.PageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
var totalCount = await multi.ReadSingleAsync<int>();
|
||||||
|
var products = (await multi.ReadAsync<Product>()).ToList();
|
||||||
|
|
||||||
|
return (products, totalCount);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Patterns
|
||||||
|
|
||||||
|
### 7. Table-Valued Parameters (Bulk Operations)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// SQL Server TVP for bulk operations
|
||||||
|
public async Task<IReadOnlyList<Product>> GetByIdsAsync(IEnumerable<string> ids)
|
||||||
|
{
|
||||||
|
// Create DataTable matching TVP structure
|
||||||
|
var table = new DataTable();
|
||||||
|
table.Columns.Add("Id", typeof(string));
|
||||||
|
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
table.Rows.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var connection = _factory.CreateConnection();
|
||||||
|
|
||||||
|
var results = await connection.QueryAsync<Product>(
|
||||||
|
"SELECT p.* FROM Products p INNER JOIN @Ids i ON p.Id = i.Id",
|
||||||
|
new { Ids = table.AsTableValuedParameter("dbo.StringIdList") });
|
||||||
|
|
||||||
|
return results.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQL to create the TVP type:
|
||||||
|
// CREATE TYPE dbo.StringIdList AS TABLE (Id NVARCHAR(40));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Stored Procedures
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<IReadOnlyList<Product>> GetTopProductsAsync(int categoryId, int count)
|
||||||
|
{
|
||||||
|
using var connection = _factory.CreateConnection();
|
||||||
|
|
||||||
|
var results = await connection.QueryAsync<Product>(
|
||||||
|
"dbo.GetTopProductsByCategory",
|
||||||
|
new { CategoryId = categoryId, TopN = count },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
return results.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// With output parameters
|
||||||
|
public async Task<(Order Order, string ConfirmationCode)> CreateOrderAsync(Order order)
|
||||||
|
{
|
||||||
|
var parameters = new DynamicParameters(new
|
||||||
|
{
|
||||||
|
order.CustomerId,
|
||||||
|
order.Total
|
||||||
|
});
|
||||||
|
parameters.Add("OrderId", dbType: DbType.Int32, direction: ParameterDirection.Output);
|
||||||
|
parameters.Add("ConfirmationCode", dbType: DbType.String, size: 20, direction: ParameterDirection.Output);
|
||||||
|
|
||||||
|
using var connection = _factory.CreateConnection();
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"dbo.CreateOrder",
|
||||||
|
parameters,
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
order.Id = parameters.Get<int>("OrderId");
|
||||||
|
var confirmationCode = parameters.Get<string>("ConfirmationCode");
|
||||||
|
|
||||||
|
return (order, confirmationCode);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Transactions
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<Order> CreateOrderWithItemsAsync(Order order, List<OrderItem> items)
|
||||||
|
{
|
||||||
|
using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
using var transaction = await connection.BeginTransactionAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Insert order
|
||||||
|
order.Id = await connection.QuerySingleAsync<int>(
|
||||||
|
"""
|
||||||
|
INSERT INTO Orders (CustomerId, Total, CreatedAt)
|
||||||
|
OUTPUT INSERTED.Id
|
||||||
|
VALUES (@CustomerId, @Total, @CreatedAt)
|
||||||
|
""",
|
||||||
|
order,
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
// Insert items
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
item.OrderId = order.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO OrderItems (OrderId, ProductId, Quantity, UnitPrice)
|
||||||
|
VALUES (@OrderId, @ProductId, @Quantity, @UnitPrice)
|
||||||
|
""",
|
||||||
|
items,
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
|
||||||
|
order.Items = items;
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Custom Type Handlers
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Register custom type handler for JSON columns
|
||||||
|
public class JsonTypeHandler<T> : SqlMapper.TypeHandler<T>
|
||||||
|
{
|
||||||
|
public override T Parse(object value)
|
||||||
|
{
|
||||||
|
if (value is string json)
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<T>(json)!;
|
||||||
|
}
|
||||||
|
return default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SetValue(IDbDataParameter parameter, T value)
|
||||||
|
{
|
||||||
|
parameter.Value = JsonSerializer.Serialize(value);
|
||||||
|
parameter.DbType = DbType.String;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register at startup
|
||||||
|
SqlMapper.AddTypeHandler(new JsonTypeHandler<ProductMetadata>());
|
||||||
|
|
||||||
|
// Now you can query directly
|
||||||
|
var product = await connection.QueryFirstAsync<Product>(
|
||||||
|
"SELECT Id, Name, Metadata FROM Products WHERE Id = @Id",
|
||||||
|
new { Id = id });
|
||||||
|
// product.Metadata is automatically deserialized from JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
### 11. Use CommandDefinition for Cancellation
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Always use CommandDefinition for async operations
|
||||||
|
var result = await connection.QueryAsync<Product>(
|
||||||
|
new CommandDefinition(
|
||||||
|
commandText: "SELECT * FROM Products WHERE CategoryId = @CategoryId",
|
||||||
|
parameters: new { CategoryId = categoryId },
|
||||||
|
cancellationToken: ct,
|
||||||
|
commandTimeout: 30));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12. Buffered vs Unbuffered Queries
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Buffered (default) - loads all results into memory
|
||||||
|
var products = await connection.QueryAsync<Product>(sql); // Returns list
|
||||||
|
|
||||||
|
// Unbuffered - streams results (lower memory for large result sets)
|
||||||
|
var products = await connection.QueryUnbufferedAsync<Product>(sql); // Returns IAsyncEnumerable
|
||||||
|
|
||||||
|
await foreach (var product in products)
|
||||||
|
{
|
||||||
|
// Process one at a time
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13. Connection Pooling Settings
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Default": "Server=localhost;Database=MyDb;User Id=sa;Password=xxx;TrustServerCertificate=True;Min Pool Size=5;Max Pool Size=100;Connection Timeout=30;"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Repository Base Class
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public abstract class DapperRepositoryBase<T> where T : class
|
||||||
|
{
|
||||||
|
protected readonly IDbConnectionFactory ConnectionFactory;
|
||||||
|
protected readonly ILogger Logger;
|
||||||
|
protected abstract string TableName { get; }
|
||||||
|
|
||||||
|
protected DapperRepositoryBase(IDbConnectionFactory factory, ILogger logger)
|
||||||
|
{
|
||||||
|
ConnectionFactory = factory;
|
||||||
|
Logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<T?> GetByIdAsync<TId>(TId id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sql = $"SELECT * FROM {TableName} WHERE Id = @Id";
|
||||||
|
|
||||||
|
using var connection = ConnectionFactory.CreateConnection();
|
||||||
|
return await connection.QueryFirstOrDefaultAsync<T>(
|
||||||
|
new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sql = $"SELECT * FROM {TableName}";
|
||||||
|
|
||||||
|
using var connection = ConnectionFactory.CreateConnection();
|
||||||
|
var results = await connection.QueryAsync<T>(
|
||||||
|
new CommandDefinition(sql, cancellationToken: ct));
|
||||||
|
|
||||||
|
return results.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<int> ExecuteAsync(
|
||||||
|
string sql,
|
||||||
|
object? parameters = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var connection = ConnectionFactory.CreateConnection();
|
||||||
|
return await connection.ExecuteAsync(
|
||||||
|
new CommandDefinition(sql, parameters, cancellationToken: ct));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ❌ Bad - SQL injection risk
|
||||||
|
var sql = $"SELECT * FROM Products WHERE Name = '{userInput}'";
|
||||||
|
|
||||||
|
// ✅ Good - Parameterized query
|
||||||
|
var sql = "SELECT * FROM Products WHERE Name = @Name";
|
||||||
|
await connection.QueryAsync<Product>(sql, new { Name = userInput });
|
||||||
|
|
||||||
|
// ❌ Bad - Not disposing connection
|
||||||
|
var connection = new SqlConnection(connectionString);
|
||||||
|
var result = await connection.QueryAsync<Product>(sql);
|
||||||
|
// Connection leak!
|
||||||
|
|
||||||
|
// ✅ Good - Using statement
|
||||||
|
using var connection = new SqlConnection(connectionString);
|
||||||
|
var result = await connection.QueryAsync<Product>(sql);
|
||||||
|
|
||||||
|
// ❌ Bad - Opening connection manually when not needed
|
||||||
|
await connection.OpenAsync(); // Dapper does this automatically
|
||||||
|
var result = await connection.QueryAsync<Product>(sql);
|
||||||
|
|
||||||
|
// ✅ Good - Let Dapper manage connection
|
||||||
|
var result = await connection.QueryAsync<Product>(sql);
|
||||||
|
```
|
||||||
@@ -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<AppDbContext, string, Task<Product?>> GetByIdQuery =
|
||||||
|
EF.CompileAsyncQuery((AppDbContext ctx, string id) =>
|
||||||
|
ctx.Products.AsNoTracking().FirstOrDefault(p => p.Id == id));
|
||||||
|
|
||||||
|
private static readonly Func<AppDbContext, int, IAsyncEnumerable<Product>> GetByCategoryQuery =
|
||||||
|
EF.CompileAsyncQuery((AppDbContext ctx, int categoryId) =>
|
||||||
|
ctx.Products.AsNoTracking().Where(p => p.CategoryId == categoryId));
|
||||||
|
|
||||||
|
public Task<Product?> GetByIdAsync(string id, CancellationToken ct)
|
||||||
|
=> GetByIdQuery(_context, id);
|
||||||
|
|
||||||
|
public IAsyncEnumerable<Product> GetByCategoryAsync(int categoryId)
|
||||||
|
=> GetByCategoryQuery(_context, categoryId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch Operations
|
||||||
|
|
||||||
|
### 6. Use ExecuteUpdate/ExecuteDelete (.NET 7+)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ✅ Good - Single SQL UPDATE
|
||||||
|
await _context.Products
|
||||||
|
.Where(p => p.CategoryId == oldCategoryId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(p => p.CategoryId, newCategoryId)
|
||||||
|
.SetProperty(p => p.UpdatedAt, DateTime.UtcNow),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
// ✅ Good - Single SQL DELETE
|
||||||
|
await _context.Products
|
||||||
|
.Where(p => p.IsDeleted && p.UpdatedAt < cutoffDate)
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
// ❌ Bad - Loads all entities into memory
|
||||||
|
var products = await _context.Products
|
||||||
|
.Where(p => p.CategoryId == oldCategoryId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var product in products)
|
||||||
|
{
|
||||||
|
product.CategoryId = newCategoryId;
|
||||||
|
}
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Bulk Insert with EFCore.BulkExtensions
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Using EFCore.BulkExtensions package
|
||||||
|
var products = GenerateLargeProductList();
|
||||||
|
|
||||||
|
// ✅ Good - Bulk insert (much faster for large datasets)
|
||||||
|
await _context.BulkInsertAsync(products, ct);
|
||||||
|
|
||||||
|
// ❌ Bad - Individual inserts
|
||||||
|
foreach (var product in products)
|
||||||
|
{
|
||||||
|
_context.Products.Add(product);
|
||||||
|
}
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connection Management
|
||||||
|
|
||||||
|
### 8. Configure Connection Pooling
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
services.AddDbContext<AppDbContext>(options =>
|
||||||
|
{
|
||||||
|
options.UseSqlServer(connectionString, sqlOptions =>
|
||||||
|
{
|
||||||
|
sqlOptions.EnableRetryOnFailure(
|
||||||
|
maxRetryCount: 3,
|
||||||
|
maxRetryDelay: TimeSpan.FromSeconds(10),
|
||||||
|
errorNumbersToAdd: null);
|
||||||
|
|
||||||
|
sqlOptions.CommandTimeout(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Performance settings
|
||||||
|
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
|
||||||
|
|
||||||
|
// Development only
|
||||||
|
if (env.IsDevelopment())
|
||||||
|
{
|
||||||
|
options.EnableSensitiveDataLogging();
|
||||||
|
options.EnableDetailedErrors();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Use DbContext Pooling
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ✅ Good - Context pooling (reduces allocation overhead)
|
||||||
|
services.AddDbContextPool<AppDbContext>(options =>
|
||||||
|
{
|
||||||
|
options.UseSqlServer(connectionString);
|
||||||
|
}, poolSize: 128);
|
||||||
|
|
||||||
|
// Instead of AddDbContext
|
||||||
|
```
|
||||||
|
|
||||||
|
## Concurrency and Transactions
|
||||||
|
|
||||||
|
### 10. Handle Concurrency with Row Versioning
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class Product
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
[Timestamp]
|
||||||
|
public byte[] RowVersion { get; set; } // SQL Server rowversion
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or with Fluent API
|
||||||
|
builder.Property(p => p.RowVersion)
|
||||||
|
.IsRowVersion();
|
||||||
|
|
||||||
|
// Handle concurrency conflicts
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException ex)
|
||||||
|
{
|
||||||
|
var entry = ex.Entries.Single();
|
||||||
|
var databaseValues = await entry.GetDatabaseValuesAsync(ct);
|
||||||
|
|
||||||
|
if (databaseValues == null)
|
||||||
|
{
|
||||||
|
// Entity was deleted
|
||||||
|
throw new NotFoundException("Product was deleted by another user");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client wins - overwrite database values
|
||||||
|
entry.OriginalValues.SetValues(databaseValues);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. Use Explicit Transactions When Needed
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await using var transaction = await _context.Database.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Multiple operations
|
||||||
|
_context.Orders.Add(order);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await _context.OrderItems.AddRangeAsync(items, ct);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await _paymentService.ProcessAsync(order.Id, ct);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Indexing Strategy
|
||||||
|
|
||||||
|
### 12. Create Indexes for Query Patterns
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class ProductConfiguration : IEntityTypeConfiguration<Product>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Product> builder)
|
||||||
|
{
|
||||||
|
// Unique index
|
||||||
|
builder.HasIndex(p => p.Sku)
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
// Composite index for common query patterns
|
||||||
|
builder.HasIndex(p => new { p.CategoryId, p.Name });
|
||||||
|
|
||||||
|
// Filtered index (SQL Server)
|
||||||
|
builder.HasIndex(p => p.Price)
|
||||||
|
.HasFilter("[IsDeleted] = 0");
|
||||||
|
|
||||||
|
// Include columns for covering index
|
||||||
|
builder.HasIndex(p => p.CategoryId)
|
||||||
|
.IncludeProperties(p => new { p.Name, p.Price });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
### ❌ Calling ToList() Too Early
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ❌ Bad - Materializes all products then filters in memory
|
||||||
|
var products = _context.Products.ToList()
|
||||||
|
.Where(p => p.Price > 100);
|
||||||
|
|
||||||
|
// ✅ Good - Filter in SQL
|
||||||
|
var products = await _context.Products
|
||||||
|
.Where(p => p.Price > 100)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Using Contains with Large Collections
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ❌ Bad - Generates massive IN clause
|
||||||
|
var ids = GetThousandsOfIds();
|
||||||
|
var products = await _context.Products
|
||||||
|
.Where(p => ids.Contains(p.Id))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// ✅ Good - Use temp table or batch queries
|
||||||
|
var products = new List<Product>();
|
||||||
|
foreach (var batch in ids.Chunk(100))
|
||||||
|
{
|
||||||
|
var batchResults = await _context.Products
|
||||||
|
.Where(p => batch.Contains(p.Id))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
products.AddRange(batchResults);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ String Concatenation in Queries
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ❌ Bad - Can't use index
|
||||||
|
var products = await _context.Products
|
||||||
|
.Where(p => (p.FirstName + " " + p.LastName).Contains(searchTerm))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// ✅ Good - Use computed column with index
|
||||||
|
builder.Property(p => p.FullName)
|
||||||
|
.HasComputedColumnSql("[FirstName] + ' ' + [LastName]");
|
||||||
|
builder.HasIndex(p => p.FullName);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring and Diagnostics
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Log slow queries
|
||||||
|
services.AddDbContext<AppDbContext>(options =>
|
||||||
|
{
|
||||||
|
options.UseSqlServer(connectionString);
|
||||||
|
|
||||||
|
options.LogTo(
|
||||||
|
filter: (eventId, level) => eventId.Id == CoreEventId.QueryExecutionPlanned.Id,
|
||||||
|
logger: (eventData) =>
|
||||||
|
{
|
||||||
|
if (eventData is QueryExpressionEventData queryData)
|
||||||
|
{
|
||||||
|
var duration = queryData.Duration;
|
||||||
|
if (duration > TimeSpan.FromSeconds(1))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Slow query detected: {Duration}ms - {Query}",
|
||||||
|
duration.TotalMilliseconds,
|
||||||
|
queryData.Expression);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user