From 235c5a61566d4cd8193dc86bc01f26011b2b0492 Mon Sep 17 00:00:00 2001 From: Tornike Matiashvili Date: Fri, 6 Feb 2026 23:36:03 +0400 Subject: [PATCH] Add dotnet-backend plugin for .NET 9 backend development Decision-tree guidance skill covering architecture selection (Clean Architecture, Vertical Slice, Modular Monolith), CQRS with MediatR, DDD patterns, EF Core 9, testing strategies, error handling, observability, and SignalR real-time patterns. Includes: - SKILL.md with interactive architecture decision flow - 3 stack templates (clean-architecture, vertical-slice, modular-monolith) - 6 reference guides (DDD, testing, EF migrations, error handling, observability, SignalR) - Marketplace registration Co-Authored-By: Claude Opus 4.6 --- .github/plugin/marketplace.json | 9 + .../skills/dotnet-backend/SKILL.md | 1003 +++++++++++++ .../dotnet-backend/references/ddd-patterns.md | 273 ++++ .../references/ef-migrations.md | 578 ++++++++ .../references/error-handling.md | 321 +++++ .../references/observability.md | 289 ++++ .../references/signalr-patterns.md | 377 +++++ .../references/testing-strategies.md | 435 ++++++ .../stacks/clean-architecture.md | 1272 +++++++++++++++++ .../dotnet-backend/stacks/modular-monolith.md | 1058 ++++++++++++++ .../dotnet-backend/stacks/vertical-slice.md | 1041 ++++++++++++++ 11 files changed, 6656 insertions(+) create mode 100644 plugins/dotnet-backend/skills/dotnet-backend/SKILL.md create mode 100644 plugins/dotnet-backend/skills/dotnet-backend/references/ddd-patterns.md create mode 100644 plugins/dotnet-backend/skills/dotnet-backend/references/ef-migrations.md create mode 100644 plugins/dotnet-backend/skills/dotnet-backend/references/error-handling.md create mode 100644 plugins/dotnet-backend/skills/dotnet-backend/references/observability.md create mode 100644 plugins/dotnet-backend/skills/dotnet-backend/references/signalr-patterns.md create mode 100644 plugins/dotnet-backend/skills/dotnet-backend/references/testing-strategies.md create mode 100644 plugins/dotnet-backend/skills/dotnet-backend/stacks/clean-architecture.md create mode 100644 plugins/dotnet-backend/skills/dotnet-backend/stacks/modular-monolith.md create mode 100644 plugins/dotnet-backend/skills/dotnet-backend/stacks/vertical-slice.md diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index a348df1..4db1d2e 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -26,6 +26,15 @@ "skills": [ "./skills/spark" ] + }, + { + "name": "dotnet-backend", + "source": "plugins/dotnet-backend", + "description": "Decision-tree guidance for .NET 9 backend development — architecture patterns, CQRS, DDD, testing strategies, and production best practices.", + "version": "1.0.0", + "skills": [ + "./skills/dotnet-backend" + ] } ] } \ No newline at end of file diff --git a/plugins/dotnet-backend/skills/dotnet-backend/SKILL.md b/plugins/dotnet-backend/skills/dotnet-backend/SKILL.md new file mode 100644 index 0000000..6bd1c77 --- /dev/null +++ b/plugins/dotnet-backend/skills/dotnet-backend/SKILL.md @@ -0,0 +1,1003 @@ +--- +name: dotnet-backend +description: Comprehensive guidance for building, architecting, and modernizing C# .NET backend applications. Use when user wants to create a new C# API, refactor an existing .NET backend, choose an architecture pattern (Clean Architecture, Vertical Slice Architecture, Modular Monolith), apply design patterns, design REST APIs, implement data access with EF Core or Dapper, set up CQRS with MediatR, apply Domain-Driven Design (DDD), plan testing strategies, add real-time communication with SignalR, configure resilience with Polly, handle authentication and authorization, manage database migrations, or modernize a legacy .NET application. Trigger phrases include "build a C# API", "create a .NET backend", "refactor my .NET app", "which architecture pattern should I use", "Clean Architecture vs Vertical Slice", "set up MediatR", "EF Core repository pattern", "should I use DDD", "add SignalR to my API", "Polly retry policy", "ProblemDetails error handling", "Modular Monolith vs microservices", "scaffold a dotnet project", "migrate my .NET Framework app", "FluentValidation pipeline", "background jobs in .NET", "API versioning in ASP.NET", "how to structure a .NET solution", and any question about C# backend architecture, patterns, or best practices. +--- + +# .NET Backend + +## Purpose + +.NET Backend is a decision-tree guide for building and modernizing C# backend applications on .NET 9. Rather than prescribing a single "correct" approach, this skill presents trade-offs for architecture patterns, data access strategies, testing approaches, and cross-cutting concerns so you can choose what fits your context. Every recommendation includes the *why* alongside the *how*. + +## When to Use This Skill + +Activate this skill when the user: + +- Wants to build a new C# backend, Web API, or service from scratch +- Needs to choose between architecture patterns (Clean Architecture, Vertical Slice, Modular Monolith) +- Asks about CQRS, MediatR, DDD, or domain modeling +- Wants guidance on data access (EF Core, Dapper, or both) +- Needs to add error handling, validation, auth, or observability to a .NET API +- Is refactoring or modernizing an existing .NET application +- Asks about real-time communication (SignalR), background processing, or resilience +- Requests help structuring a .NET solution or choosing between Minimal APIs and Controllers + +## Quick Start Workflow + +Scaffold a basic .NET 9 Web API project: + +```bash +# Create solution and API project +dotnet new sln -n MyApp +dotnet new webapi -n MyApp.Api --use-minimal-apis +dotnet sln add MyApp.Api + +# Add class libraries for layered structure +dotnet new classlib -n MyApp.Application +dotnet new classlib -n MyApp.Domain +dotnet new classlib -n MyApp.Infrastructure +dotnet sln add MyApp.Application MyApp.Domain MyApp.Infrastructure + +# Wire up project references +dotnet add MyApp.Api reference MyApp.Application +dotnet add MyApp.Application reference MyApp.Domain +dotnet add MyApp.Infrastructure reference MyApp.Application +dotnet add MyApp.Api reference MyApp.Infrastructure + +# Install foundational NuGet packages +dotnet add MyApp.Api package Serilog.AspNetCore +dotnet add MyApp.Api package Asp.Versioning.Http +dotnet add MyApp.Application package MediatR +dotnet add MyApp.Application package FluentValidation.DependencyInjectionExtensions +dotnet add MyApp.Infrastructure package Microsoft.EntityFrameworkCore.SqlServer +dotnet add MyApp.Infrastructure package Microsoft.EntityFrameworkCore.Design +``` + +Adapt the project count and references to your chosen architecture (see Section 4). + +## Architecture Decision Tree + +Choose your architecture based on team size, domain complexity, and how you expect the system to evolve. + +| Factor | Clean Architecture | Vertical Slice | Modular Monolith | +|--------|-------------------|----------------|------------------| +| **Core idea** | Strict layer separation; domain at center | Feature cohesion; each slice owns its full stack | Independent modules behind explicit boundaries | +| **Best when** | Large team, complex domain, long-lived product | Small-to-mid team, feature-oriented delivery | You want service boundaries without distributed infra | +| **Trade-off** | More abstractions, indirection | Less reuse across slices, possible duplication | Module boundary discipline required | +| **Evolves toward** | Stays monolith or extracts bounded contexts | Stays monolith or splits slices into services | Breaks modules into microservices when ready | +| **Stack file** | `stacks/clean-architecture.md` | `stacks/vertical-slice.md` | `stacks/modular-monolith.md` | + +**Decision guide:** +1. *Greenfield CRUD app with a small team?* Start with Vertical Slice. Low ceremony, ship fast. +2. *Complex domain with deep business logic?* Clean Architecture gives you isolated domain tests and clear dependency direction. +3. *Multiple bounded contexts that may become services later?* Modular Monolith lets you draw service boundaries now without paying the distributed-systems tax yet. + +## Core Tech Stack (Shared Foundation) + +All architectures share this foundation on .NET 9: + +| Concern | Choice | Notes | +|---------|--------|-------| +| **Runtime** | .NET 9 | Current release; LTS (.NET 8) also viable | +| **CQRS** | MediatR | Pipeline behaviors for cross-cutting concerns | +| **Validation** | FluentValidation | API/pipeline input validation | +| **Data (primary)** | EF Core 9 | ORM for 90% of data access | +| **Data (escape hatch)** | Dapper | Raw SQL for performance-critical queries | +| **Error handling** | ProblemDetails (RFC 9457) | Global exception middleware | +| **Logging** | Serilog *or* Microsoft.Extensions.Logging | Both integrate with OpenTelemetry | +| **Resilience** | Polly v8 | Via Microsoft.Extensions.Http.Resilience | +| **API versioning** | Asp.Versioning | URL segment strategy | +| **Auth** | Policy-based authorization | Assumes external IdP | +| **Real-time** | SignalR | Strongly-typed hubs | + +## API Style Decision + +.NET 9 supports two first-class API styles. Neither is universally better. + +| Consideration | Minimal APIs | Controllers | +|---------------|-------------|-------------| +| **Boilerplate** | Less ceremony, lambda-based | More structure, class-based | +| **Discoverability** | Endpoints defined in code; needs grouping discipline | Conventional routing; easy to browse | +| **Filters/middleware** | Endpoint filters (newer API) | Action filters, model binding (mature) | +| **OpenAPI** | Built-in with `WithOpenApi()` | Swashbuckle or NSwag | +| **Best for** | Small APIs, microservices, vertical slices | Large APIs, teams familiar with MVC, complex model binding | +| **Testability** | `WebApplicationFactory` works for both | Same | + +```csharp +// using MediatR; +// using Microsoft.AspNetCore.Mvc; + +// Minimal API example +app.MapGet("/api/v1/orders/{id}", async (int id, ISender sender) => +{ + var order = await sender.Send(new GetOrderQuery(id)); + return Results.Ok(order); +}) +.WithName("GetOrder") +.WithOpenApi(); + +// Controller equivalent +[ApiController] +[Route("api/v1/[controller]")] +public class OrdersController(ISender sender) : ControllerBase +{ + [HttpGet("{id}")] + public async Task Get(int id) + { + var order = await sender.Send(new GetOrderQuery(id)); + return Ok(order); + } +} +``` + +## Data Access Strategy + +### EF Core as the Primary ORM + +EF Core 9 is the default for all data access. DbContext already implements both the Unit of Work and Repository patterns. **Do not wrap DbContext in a custom repository layer** -- it adds indirection without meaningful benefit in most applications. + +```csharp +// using MediatR; +// using Microsoft.EntityFrameworkCore; + +// Direct DbContext injection -- no repository wrapper needed +public class GetOrderQueryHandler(AppDbContext db) + : IRequestHandler +{ + public async Task Handle(GetOrderQuery request, CancellationToken ct) + { + var order = await db.Orders + .Include(o => o.Items) + .Where(o => o.Id == request.Id) + .Select(o => new OrderDto(o.Id, o.Status, o.Items.Count)) + .FirstOrDefaultAsync(ct) + ?? throw new NotFoundException(nameof(Order), request.Id); + + return order; + } +} +``` + +### Dapper Escape Hatch + +When EF Core's query translation is too slow or you need hand-tuned SQL, use Dapper alongside EF Core. Inject `AppDbContext` and use `.Database.GetDbConnection()` to get the underlying connection so both share the same connection pool. + +```csharp +// using Dapper; +// using Microsoft.EntityFrameworkCore; + +// Performance-critical reporting query using Dapper +public class GetSalesReportHandler(AppDbContext db) + : IRequestHandler +{ + public async Task Handle(GetSalesReportQuery request, CancellationToken ct) + { + var connection = db.Database.GetDbConnection(); + + const string sql = """ + SELECT Category, SUM(Amount) as Total, COUNT(*) as Count + FROM Orders + WHERE OrderDate >= @From AND OrderDate <= @To + GROUP BY Category + ORDER BY Total DESC + """; + + var results = await connection.QueryAsync(sql, new + { + From = request.FromDate, + To = request.ToDate + }); + + return new SalesReport(results.ToList()); + } +} +``` + +## CQRS with MediatR + +Separate reads (queries) from writes (commands). MediatR pipeline behaviors handle cross-cutting concerns without polluting handler logic. + +### Command / Query Separation + +```csharp +// using MediatR; + +// Command -- changes state, returns minimal result +public record CreateOrderCommand(string CustomerId, List Items) + : IRequest; + +// Query -- reads state, returns projection +public record GetOrderQuery(int Id) : IRequest; +``` + +### Pipeline Behaviors for Cross-Cutting Concerns + +```csharp +// using FluentValidation; +// using MediatR; +// using System.Diagnostics; + +// Validation behavior -- runs FluentValidation before every handler +public class ValidationBehavior( + IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken ct) + { + if (!validators.Any()) return await next(); + + var context = new ValidationContext(request); + var failures = (await Task.WhenAll( + validators.Select(v => v.ValidateAsync(context, ct)))) + .SelectMany(r => r.Errors) + .Where(f => f is not null) + .ToList(); + + if (failures.Count > 0) + throw new ValidationException(failures); + + return await next(); + } +} + +// Logging behavior +public class LoggingBehavior( + ILogger> logger) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken ct) + { + var name = typeof(TRequest).Name; + logger.LogInformation("Handling {RequestName}", name); + + var sw = Stopwatch.StartNew(); + var response = await next(); + sw.Stop(); + + logger.LogInformation("Handled {RequestName} in {ElapsedMs}ms", name, sw.ElapsedMilliseconds); + return response; + } +} +``` + +### DI Registration + +```csharp +services.AddMediatR(cfg => +{ + cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); +}); +``` + +## Error Handling Strategy + +Throw typed domain exceptions in handlers. A global middleware catches them and maps to RFC 9457 ProblemDetails responses. + +### Custom Exception Hierarchy + +```csharp +// using System; + +public abstract class AppException(string message, int statusCode) + : Exception(message) +{ + public int StatusCode { get; } = statusCode; +} + +public class NotFoundException(string entity, object key) + : AppException($"{entity} with key '{key}' was not found.", 404); + +public class ConflictException(string message) + : AppException(message, 409); + +public class ForbiddenException(string message = "You do not have permission.") + : AppException(message, 403); + +public class BadRequestException(string message) + : AppException(message, 400); +``` + +### Global Exception Handling Middleware + +```csharp +// using FluentValidation; +// using Microsoft.AspNetCore.Http; +// using Microsoft.AspNetCore.Mvc; +// using Microsoft.Extensions.Logging; + +public class ExceptionHandlingMiddleware( + RequestDelegate next, + ILogger logger) +{ + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (ValidationException ex) + { + context.Response.StatusCode = 422; + await context.Response.WriteAsJsonAsync(new ProblemDetails + { + Status = 422, + Title = "Validation Error", + Detail = "One or more validation errors occurred.", + Extensions = { ["errors"] = ex.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()) } + }); + } + catch (AppException ex) + { + logger.LogWarning(ex, "Application exception: {Message}", ex.Message); + context.Response.StatusCode = ex.StatusCode; + await context.Response.WriteAsJsonAsync(new ProblemDetails + { + Status = ex.StatusCode, + Title = ex.GetType().Name.Replace("Exception", ""), + Detail = ex.Message + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Unhandled exception"); + context.Response.StatusCode = 500; + await context.Response.WriteAsJsonAsync(new ProblemDetails + { + Status = 500, + Title = "Internal Server Error", + Detail = "An unexpected error occurred." + }); + } + } +} + +// Registration +app.UseMiddleware(); +``` + +## Validation Strategy + +Use dual validation: **FluentValidation** at the API boundary for input shape, and **domain invariants** inside entity constructors and methods for business rules. + +### FluentValidation (Input Shape) + +```csharp +// using FluentValidation; + +public class CreateOrderCommandValidator : AbstractValidator +{ + public CreateOrderCommandValidator() + { + RuleFor(x => x.CustomerId).NotEmpty().MaximumLength(50); + RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item."); + RuleForEach(x => x.Items).ChildRules(item => + { + item.RuleFor(i => i.ProductId).NotEmpty(); + item.RuleFor(i => i.Quantity).GreaterThan(0); + }); + } +} +``` + +FluentValidation runs automatically via the `ValidationBehavior` pipeline behavior shown in Section 8. + +### Domain Invariants (Business Rules) + +```csharp +public class Order +{ + public int Id { get; private set; } + public OrderStatus Status { get; private set; } + private readonly List _items = []; + public IReadOnlyList Items => _items.AsReadOnly(); + + public void AddItem(string productId, int quantity, decimal unitPrice) + { + if (Status != OrderStatus.Draft) + throw new ConflictException("Cannot add items to a non-draft order."); + + if (quantity <= 0) + throw new BadRequestException("Quantity must be positive."); + + _items.Add(new OrderItem(productId, quantity, unitPrice)); + } + + public void Submit() + { + if (_items.Count == 0) + throw new ConflictException("Cannot submit an empty order."); + + Status = OrderStatus.Submitted; + } +} +``` + +## DDD Decision Tree + +Domain-Driven Design adds overhead. Use it when the domain justifies it. + +| Signal | Recommendation | +|--------|---------------| +| Business logic is complex, rules change often, domain experts are available | Full DDD tactical patterns: aggregates, value objects, domain events | +| Mostly CRUD with simple validation | Anemic models are fine -- keep it simple | +| Mix of complex and simple bounded contexts | DDD in complex contexts, simple models in CRUD contexts | + +### When DDD Is Worth It + +```csharp +// using MediatR; + +// Value Object +public record Money(decimal Amount, string Currency) +{ + public static Money operator +(Money a, Money b) + { + if (a.Currency != b.Currency) + throw new InvalidOperationException("Cannot add different currencies."); + return new Money(a.Amount + b.Amount, a.Currency); + } +} + +// Domain Event +public record OrderSubmittedEvent(int OrderId, string CustomerId, DateTime SubmittedAt) + : INotification; + +// Raising domain events from aggregate +public abstract class AggregateRoot +{ + private readonly List _domainEvents = []; + public IReadOnlyList DomainEvents => _domainEvents.AsReadOnly(); + protected void RaiseDomainEvent(INotification domainEvent) => _domainEvents.Add(domainEvent); + public void ClearDomainEvents() => _domainEvents.Clear(); +} +``` + +### When DDD Is Not Worth It + +For CRUD-heavy contexts, a flat model with FluentValidation is simpler and more maintainable. Do not force aggregates and value objects where there is no complex invariant logic to protect. + +## Authentication & Authorization + +This skill focuses on the **consumption side** of auth. Assume an external Identity Provider (Keycloak, Auth0, Entra ID) issues JWTs. + +### JWT Bearer Setup + +```csharp +// using Microsoft.AspNetCore.Authentication.JwtBearer; + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = builder.Configuration["Auth:Authority"]; + options.Audience = builder.Configuration["Auth:Audience"]; + }); +``` + +### Policy-Based Authorization + +```csharp +builder.Services.AddAuthorizationBuilder() + .AddPolicy("AdminOnly", policy => policy.RequireRole("Admin")) + .AddPolicy("CanManageOrders", policy => + policy.RequireClaim("permission", "orders:manage")); + +// Apply to endpoints +app.MapDelete("/api/v1/orders/{id}", async (int id, ISender sender) => +{ + await sender.Send(new DeleteOrderCommand(id)); + return Results.NoContent(); +}) +.RequireAuthorization("CanManageOrders"); + +// Or on controllers +[Authorize(Policy = "AdminOnly")] +[HttpPost("refund")] +public async Task Refund(RefundCommand command) + => Ok(await sender.Send(command)); +``` + +## API Versioning + +Use Asp.Versioning with URL segment strategy for explicit, discoverable API versions. + +```csharp +// using Asp.Versioning; + +builder.Services.AddApiVersioning(options => +{ + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + options.ApiVersionReader = new UrlSegmentApiVersionReader(); +}); + +// Minimal API versioning +var versionSet = app.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1, 0)) + .HasApiVersion(new ApiVersion(2, 0)) + .Build(); + +app.MapGet("/api/v{version:apiVersion}/orders", GetOrdersV1) + .WithApiVersionSet(versionSet) + .MapToApiVersion(new ApiVersion(1, 0)); + +app.MapGet("/api/v{version:apiVersion}/orders", GetOrdersV2) + .WithApiVersionSet(versionSet) + .MapToApiVersion(new ApiVersion(2, 0)); +``` + +## Background Processing + +Choose based on complexity of your scheduling and retry needs. + +| Need | Solution | When | +|------|----------|------| +| Simple in-process queue | `IHostedService` + `Channel` | Fire-and-forget tasks, no persistence needed | +| Persistent jobs, scheduling, retries | Hangfire *or* Quartz.NET | Recurring jobs, must survive app restarts, need dashboards | + +### IHostedService + Channels (Simple) + +```csharp +// using System.Threading.Channels; +// using Microsoft.Extensions.Hosting; + +public class EmailChannel +{ + private readonly Channel _channel = Channel.CreateUnbounded(); + public ChannelWriter Writer => _channel.Writer; + public ChannelReader Reader => _channel.Reader; +} + +public class EmailBackgroundService(EmailChannel channel, IEmailSender sender) + : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken ct) + { + await foreach (var message in channel.Reader.ReadAllAsync(ct)) + { + await sender.SendAsync(message); + } + } +} +``` + +### Hangfire (Persistent) + +```csharp +// using Hangfire; + +builder.Services.AddHangfire(config => + config.UseSqlServerStorage(connectionString)); +builder.Services.AddHangfireServer(); + +// Enqueue a job +BackgroundJob.Enqueue(x => x.GenerateMonthlyReport()); + +// Recurring job +RecurringJob.AddOrUpdate( + "daily-sync", + x => x.SyncExternalData(), + Cron.Daily); +``` + +## Real-time Communication (SignalR) + +### Strongly-Typed Hub + +```csharp +// using Microsoft.AspNetCore.SignalR; + +public interface IOrderNotifications +{ + Task OrderStatusChanged(int orderId, string newStatus); + Task NewOrderPlaced(int orderId, string customerName); +} + +public class OrderHub : Hub +{ + public async Task JoinOrderGroup(int orderId) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"order-{orderId}"); + } + + public async Task LeaveOrderGroup(int orderId) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"order-{orderId}"); + } +} +``` + +### Sending Notifications from a Handler + +```csharp +// using MediatR; +// using Microsoft.AspNetCore.SignalR; + +public class OrderSubmittedHandler(IHubContext hub) + : INotificationHandler +{ + public async Task Handle(OrderSubmittedEvent notification, CancellationToken ct) + { + await hub.Clients.Group($"order-{notification.OrderId}") + .OrderStatusChanged(notification.OrderId, "Submitted"); + } +} +``` + +### SignalR Auth + +```csharp +builder.Services.AddSignalR(); + +app.MapHub("/hubs/orders") + .RequireAuthorization(); + +// Client connects with access token +var connection = new HubConnectionBuilder() + .WithUrl("https://api.example.com/hubs/orders", options => + { + options.AccessTokenProvider = () => Task.FromResult(token); + }) + .WithAutomaticReconnect() + .Build(); +``` + +## Resilience (Polly v8) + +Use `Microsoft.Extensions.Http.Resilience` for HTTP client resilience. Polly v8 uses a pipeline-based API. + +```csharp +// using Microsoft.Extensions.Http.Resilience; + +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://catalog-service/"); +}) +.AddStandardResilienceHandler(options => +{ + options.Retry.MaxRetryAttempts = 3; + options.Retry.Delay = TimeSpan.FromMilliseconds(500); + options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(10); + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(5); + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(30); +}); +``` + +For custom resilience pipelines beyond HTTP: + +```csharp +// using Microsoft.EntityFrameworkCore; +// using Microsoft.Extensions.DependencyInjection; +// using Polly; +// using Polly.Retry; + +builder.Services.AddResiliencePipeline("database-retry", pipelineBuilder => +{ + pipelineBuilder + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = TimeSpan.FromMilliseconds(200), + BackoffType = DelayBackoffType.Exponential, + ShouldHandle = new PredicateBuilder().Handle() + }) + .AddTimeout(TimeSpan.FromSeconds(10)); +}); +``` + +## Observability + +### Serilog vs Built-in Logging + +| Factor | Serilog | Microsoft.Extensions.Logging | +|--------|---------|------------------------------| +| **Structured logging** | First-class, rich enrichers, sinks ecosystem | Supported, fewer built-in enrichers | +| **Sink variety** | 100+ sinks (Seq, Elasticsearch, Datadog, etc.) | Providers via NuGet, fewer choices | +| **Configuration** | File-based config, hot reload | `appsettings.json`, standard | +| **OpenTelemetry** | Full integration via Serilog.Sinks.OpenTelemetry | Native integration | +| **Overhead** | Small additional dependency | Zero additional dependencies | + +Both are valid. Use Serilog if you need rich structured logging with specialized sinks. Use built-in logging if you want zero extra dependencies and your observability platform has a native .NET provider. + +### OpenTelemetry Setup + +```csharp +// using OpenTelemetry.Metrics; +// using OpenTelemetry.Trace; + +builder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddEntityFrameworkCoreInstrumentation() + .AddOtlpExporter()) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddOtlpExporter()); +``` + +## DI Organization (Extension Method Modules) + +Group service registrations by feature or layer using `IServiceCollection` extension methods. This keeps `Program.cs` clean and makes each module self-contained. + +```csharp +// using FluentValidation; +// using MediatR; +// using Microsoft.EntityFrameworkCore; +// using Microsoft.Extensions.Configuration; +// using Microsoft.Extensions.DependencyInjection; + +// In MyApp.Application project +public static class ApplicationServiceRegistration +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(typeof(ApplicationServiceRegistration).Assembly); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + }); + + services.AddValidatorsFromAssembly( + typeof(ApplicationServiceRegistration).Assembly); + + return services; + } +} + +// In MyApp.Infrastructure project +public static class InfrastructureServiceRegistration +{ + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, IConfiguration config) + { + services.AddDbContext(options => + options.UseSqlServer(config.GetConnectionString("Default"))); + + // For Dapper queries, inject AppDbContext and use: + // var connection = db.Database.GetDbConnection(); + // No separate IDbConnection registration needed + + return services; + } +} + +// Clean Program.cs +builder.Services + .AddApplication() + .AddInfrastructure(builder.Configuration); +``` + +## Configuration (IOptions Pattern) + +Use the `IOptions` family for typed configuration. Choose the right interface based on your reload needs: + +- **`IOptions`** -- Singleton, read once at startup. Best for settings that never change. +- **`IOptionsSnapshot`** -- Scoped, re-reads per request. Use in request-scoped services when config may change between deployments. +- **`IOptionsMonitor`** -- Singleton with change notifications. Use in singleton services that need to react to config changes without restart. + +```csharp +// using Microsoft.Extensions.Options; + +builder.Services.Configure(builder.Configuration.GetSection("Smtp")); + +// Inject where needed +public class EmailSender(IOptionsMonitor smtpOptions) +{ + public async Task SendAsync(EmailMessage message) + { + var settings = smtpOptions.CurrentValue; // always latest + // ... + } +} +``` + +## Testing Strategy + +Testing strategy varies by architecture. Match your approach to how the code is structured. + +| Architecture | Primary Test Style | Rationale | +|--------------|--------------------|-----------| +| **Clean Architecture** | Unit tests (domain + handlers with mocked deps) | Isolated domain layer, dependency inversion makes mocking natural | +| **Vertical Slice** | Integration tests (`WebApplicationFactory`, real pipeline) | Each slice is end-to-end; testing the pipeline is more valuable than testing parts | +| **Modular Monolith** | Module integration tests + cross-module contract tests | Validate module behavior and that module boundaries hold | + +### Integration Test Example (WebApplicationFactory) + +```csharp +// using System.Net; +// using System.Net.Http.Json; +// using FluentAssertions; +// using Microsoft.AspNetCore.Mvc; +// using Microsoft.AspNetCore.Mvc.Testing; + +public class OrdersApiTests(WebApplicationFactory factory) + : IClassFixture> +{ + [Fact] + public async Task CreateOrder_ReturnsCreated() + { + var client = factory.CreateClient(); + var command = new { CustomerId = "cust-1", Items = new[] + { + new { ProductId = "prod-1", Quantity = 2 } + }}; + + var response = await client.PostAsJsonAsync("/api/v1/orders", command); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task GetOrder_NotFound_ReturnsProblemDetails() + { + var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/v1/orders/99999"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + var problem = await response.Content.ReadFromJsonAsync(); + problem!.Status.Should().Be(404); + } +} +``` + +### Unit Test Example (Handler with Mocked DbContext) + +```csharp +// using FluentAssertions; +// using Xunit; + +[Fact] +public async Task GetOrder_ReturnsDto_WhenOrderExists() +{ + var db = CreateInMemoryDbContext(); + db.Orders.Add(new Order { Id = 1, Status = OrderStatus.Draft }); + await db.SaveChangesAsync(); + + var handler = new GetOrderQueryHandler(db); + + var result = await handler.Handle(new GetOrderQuery(1), CancellationToken.None); + + result.Id.Should().Be(1); +} +``` + +## DB Migrations (EF Core Best Practices) + +### Creating and Applying Migrations + +```bash +# Create a migration +dotnet ef migrations add AddOrderTable -p MyApp.Infrastructure -s MyApp.Api + +# Apply to local database +dotnet ef database update -p MyApp.Infrastructure -s MyApp.Api + +# Generate idempotent SQL script for CI/CD +dotnet ef migrations script --idempotent -p MyApp.Infrastructure -s MyApp.Api -o migrate.sql +``` + +### Migration Bundles for CI/CD + +```bash +# Build a self-contained migration executable +dotnet ef migrations bundle -p MyApp.Infrastructure -s MyApp.Api -o efbundle + +# Run in production pipeline +./efbundle --connection "Server=prod;Database=MyApp;..." +``` + +### Best Practices + +- **Never call `Database.Migrate()` in production startup** -- use migration bundles or idempotent scripts in your deployment pipeline. +- **Data seeding** -- use `HasData()` in `OnModelCreating` for reference data only. Use migrations for structural seeding. +- **Team conflict handling** -- when two developers create migrations concurrently, delete the conflicting migration, merge code first, then create a new migration from the merged state. +- **Always review generated SQL** -- run `dotnet ef migrations script` and inspect before applying to shared environments. + +## Project Structure Templates + +### Clean Architecture Layout + +``` +MyApp/ + MyApp.sln + src/ + MyApp.Domain/ # Entities, value objects, domain events, interfaces + MyApp.Application/ # Commands, queries, handlers, DTOs, validators + MyApp.Infrastructure/ # DbContext, EF configs, external services + MyApp.Api/ # Program.cs, endpoints/controllers, middleware + tests/ + MyApp.Domain.Tests/ + MyApp.Application.Tests/ + MyApp.Api.IntegrationTests/ +``` + +### Vertical Slice Layout + +``` +MyApp/ + MyApp.sln + src/ + MyApp.Api/ + Features/ + Orders/ + CreateOrder.cs # Command + Handler + Validator + Endpoint + GetOrder.cs # Query + Handler + Endpoint + OrderDto.cs + Products/ + GetProducts.cs + CreateProduct.cs + Common/ + Behaviors/ + Middleware/ + Data/ + AppDbContext.cs + tests/ + MyApp.Api.Tests/ +``` + +### Modular Monolith Layout + +``` +MyApp/ + MyApp.sln + src/ + MyApp.Host/ # Composition root, Program.cs + Modules/ + Orders/ + MyApp.Orders.Api/ # Module endpoints + MyApp.Orders.Core/ # Commands, queries, domain + MyApp.Orders.Infra/ # Module-specific data access + MyApp.Orders.Contracts/ # Public DTOs and integration events + Catalog/ + MyApp.Catalog.Api/ + MyApp.Catalog.Core/ + MyApp.Catalog.Infra/ + MyApp.Catalog.Contracts/ + MyApp.Shared/ # Cross-cutting: middleware, base classes + tests/ + Modules/ + MyApp.Orders.Tests/ + MyApp.Catalog.Tests/ + MyApp.Integration.Tests/ +``` + +## References + +Detailed guidance for each architecture pattern is available in the companion files: + +- **`stacks/clean-architecture.md`** -- Full Clean Architecture template with project references, folder conventions, and sample code +- **`stacks/vertical-slice.md`** -- Vertical Slice template with feature folder conventions and minimal ceremony patterns +- **`stacks/modular-monolith.md`** -- Modular Monolith template with module boundary patterns, integration events, and contract testing + +## Reference Guides + +Detailed guidance on specific topics: + +| Reference | Purpose | +|-----------|---------| +| `references/ddd-patterns.md` | DDD tactical patterns — aggregates, value objects, domain events, strongly-typed IDs | +| `references/testing-strategies.md` | Architecture-specific testing ratios, Testcontainers + Respawn setup, test examples | +| `references/observability.md` | Logging (Serilog vs built-in), OpenTelemetry, correlation IDs, health checks | +| `references/signalr-patterns.md` | Strongly-typed hubs, auth, MediatR integration, Redis backplane, TypeScript client | +| `references/ef-migrations.md` | Migration workflow, bundles, expand-contract pattern, modular monolith schemas | +| `references/error-handling.md` | Exception hierarchy, IExceptionHandler, ProblemDetails, FluentValidation pipeline | + +--- + +**Remember**: There is no single "correct" architecture. Start with the pattern that matches your team's size and domain complexity. Keep things simple early, refactor when complexity demands it, and let the decision trees in this guide help you navigate trade-offs rather than prescribe answers. diff --git a/plugins/dotnet-backend/skills/dotnet-backend/references/ddd-patterns.md b/plugins/dotnet-backend/skills/dotnet-backend/references/ddd-patterns.md new file mode 100644 index 0000000..b6408f2 --- /dev/null +++ b/plugins/dotnet-backend/skills/dotnet-backend/references/ddd-patterns.md @@ -0,0 +1,273 @@ +# DDD Tactical Patterns in C# (.NET 9) + +## 1. When DDD Is Worth It -- Decision Tree + +| Domain Complexity | Approach | What You Use | +|---|---|---| +| **High** -- complex rules, many invariants, evolving logic | Full DDD | Aggregates, value objects, domain events, specifications | +| **Moderate** -- some business rules, mostly data-oriented | Lightweight DDD | Value objects + domain events only | +| **Low** -- pure CRUD, forms-over-data, admin panels | Anemic models | DTOs, EF Core entities with public setters, no domain layer | + +**Rule of thumb:** "Save this form to the database" = skip DDD. "But only when" / "unless" / "depending on the state" = you need DDD. + +--- + +## 2. Aggregate Design + +- Aggregate root is the consistency boundary and only entry point. +- Keep aggregates small. Reference other aggregates by ID, not object reference. +- One transaction = one aggregate. +- Enforce all invariants inside aggregate methods. Never expose public setters. + +```csharp +public sealed class Order : AuditableEntity +{ + private readonly List _items = []; + public CustomerId CustomerId { get; private set; } + public OrderStatus Status { get; private set; } + public Money TotalAmount { get; private set; } + public IReadOnlyList Items => _items.AsReadOnly(); + private Order() { } // EF Core constructor + + public static Order Create(CustomerId customerId) + { + var order = new Order + { + Id = OrderId.New(), CustomerId = customerId, + Status = OrderStatus.Draft, TotalAmount = Money.Zero("USD") + }; + order.AddDomainEvent(new OrderCreatedEvent(order.Id)); + return order; + } + + public void AddItem(ProductId productId, int quantity, Money unitPrice) + { + if (Status != OrderStatus.Draft) + throw new DomainException("Cannot modify a non-draft order."); + if (quantity <= 0) + throw new DomainException("Quantity must be positive."); + + var existing = _items.FirstOrDefault(i => i.ProductId == productId); + if (existing is not null) existing.IncreaseQuantity(quantity); + else _items.Add(new OrderItem(productId, quantity, unitPrice)); + RecalculateTotal(); + } + + public void Submit() + { + if (Status != OrderStatus.Draft) + throw new DomainException("Only draft orders can be submitted."); + if (_items.Count == 0) + throw new DomainException("Cannot submit an order with no items."); + Status = OrderStatus.Submitted; + AddDomainEvent(new OrderSubmittedEvent(Id, CustomerId, TotalAmount)); + } + + private void RecalculateTotal() => + TotalAmount = _items.Select(i => i.Total) + .Aggregate(Money.Zero("USD"), (acc, m) => acc + m); +} +``` + +--- + +## 3. Value Objects + +Use C# `record` types -- structural equality, immutability, and concise syntax for free. + +```csharp +using System; + +public sealed record Money(decimal Amount, string Currency) +{ + public static Money Zero(string currency) => new(0m, currency); + public static Money operator +(Money left, Money right) + { + if (left.Currency != right.Currency) + throw new DomainException("Cannot add money with different currencies."); + return new Money(left.Amount + right.Amount, left.Currency); + } + public static Money operator *(Money money, int qty) => new(money.Amount * qty, money.Currency); +} + +public sealed record Address(string Street, string City, string State, string ZipCode, string Country); + +public sealed record Email +{ + public string Value { get; } + public Email(string value) + { + if (string.IsNullOrWhiteSpace(value) || !value.Contains('@')) + throw new DomainException($"'{value}' is not a valid email address."); + Value = value.Trim().ToLowerInvariant(); + } +} +``` + +--- + +## 4. Domain Events + +Use MediatR `INotification`. Collect events during the operation, dispatch after `SaveChanges` succeeds. + +```csharp +using MediatR; + +public sealed record OrderSubmittedEvent( + OrderId OrderId, CustomerId CustomerId, Money TotalAmount) : INotification; + +public interface IHasDomainEvents +{ + IReadOnlyList DomainEvents { get; } + void ClearDomainEvents(); +} + +public abstract class Entity : IHasDomainEvents where TId : notnull +{ + public TId Id { get; protected set; } = default!; + private readonly List _domainEvents = []; + public IReadOnlyList DomainEvents => _domainEvents.AsReadOnly(); + protected void AddDomainEvent(INotification e) => _domainEvents.Add(e); + public void ClearDomainEvents() => _domainEvents.Clear(); +} +``` + +### Dispatching After SaveChanges + +```csharp +using MediatR; +using Microsoft.EntityFrameworkCore; + +public override async Task SaveChangesAsync(CancellationToken ct = default) +{ + var entities = ChangeTracker.Entries() + .Where(e => e.Entity is IHasDomainEvents) + .Select(e => (IHasDomainEvents)e.Entity) + .Where(e => e.DomainEvents.Any()) + .ToList(); + + var events = entities.SelectMany(e => e.DomainEvents).ToList(); + entities.ForEach(e => e.ClearDomainEvents()); + + var result = await base.SaveChangesAsync(ct); + + foreach (var domainEvent in events) + await _publisher.Publish(domainEvent, ct); + + return result; +} +``` + +### Event Handler + +```csharp +using MediatR; + +public sealed class SendOrderConfirmationHandler(IEmailService emailService) + : INotificationHandler +{ + public async Task Handle(OrderSubmittedEvent notification, CancellationToken ct) => + await emailService.SendOrderConfirmationAsync( + notification.CustomerId, notification.OrderId, ct); +} +``` + +--- + +## 5. Entity Base Classes + +### Strongly-Typed IDs + +Use `record struct` -- value types, stack-allocated, structural equality. + +```csharp +public readonly record struct OrderId(Guid Value) +{ + public static OrderId New() => new(Guid.NewGuid()); + public override string ToString() => Value.ToString(); +} + +public readonly record struct CustomerId(Guid Value) +{ + public static CustomerId New() => new(Guid.NewGuid()); + public override string ToString() => Value.ToString(); +} +``` + +### AuditableEntity + +```csharp +using Microsoft.EntityFrameworkCore; + +public abstract class AuditableEntity : Entity where TId : notnull +{ + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } +} + +// In DbContext.SaveChangesAsync, before base.SaveChangesAsync: +foreach (var entry in ChangeTracker.Entries>()) +{ + if (entry.State == EntityState.Added) entry.Entity.CreatedAt = DateTimeOffset.UtcNow; + if (entry.State == EntityState.Modified) entry.Entity.UpdatedAt = DateTimeOffset.UtcNow; +} +``` + +--- + +## 6. EF Core Mapping for DDD + +Use `IEntityTypeConfiguration`. No repository pattern -- `DbContext` is already Unit of Work + Repository. + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public sealed class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(o => o.Id); + builder.Property(o => o.Id).HasConversion(id => id.Value, v => new OrderId(v)); + builder.Property(o => o.CustomerId).HasConversion(id => id.Value, v => new CustomerId(v)); + builder.Ignore(o => o.DomainEvents); + + // Value object as owned type + builder.OwnsOne(o => o.TotalAmount, money => + { + money.Property(m => m.Amount).HasColumnName("TotalAmount").HasPrecision(18, 2); + money.Property(m => m.Currency).HasColumnName("TotalCurrency").HasMaxLength(3); + }); + + // Private collection via backing field + builder.Navigation(o => o.Items).HasField("_items"); + builder.OwnsMany(o => o.Items, item => + { + item.WithOwner().HasForeignKey("OrderId"); + item.Property("Id").ValueGeneratedOnAdd(); + item.HasKey("Id"); + item.Property(i => i.ProductId).HasConversion(id => id.Value, v => new ProductId(v)); + item.OwnsOne(i => i.UnitPrice, m => + { + m.Property(x => x.Amount).HasColumnName("UnitPrice").HasPrecision(18, 2); + m.Property(x => x.Currency).HasColumnName("UnitCurrency").HasMaxLength(3); + }); + }); + } +} +``` + +--- + +## 7. Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Do This Instead | +|---|---|---| +| **Anemic model when logic exists** | Rules leak into services, duplicated validation | Put behavior on the entity | +| **Repository wrapping EF Core** | Unnecessary abstraction; DbContext is already UoW + Repo | Use DbContext directly | +| **Large aggregates** | Lock contention, slow hydration | Split smaller, reference by ID | +| **Events inside the transaction** | Side effects roll back or cause partial failures | Dispatch after SaveChanges via IHasDomainEvents | +| **Public setters on entities** | Any code can create invalid state | Private setters + behavior methods | +| **Primitive obsession** | CustomerIds confused with OrderIds | Strongly-typed IDs and value objects | +| **Logic in controllers/handlers** | Untestable, scattered rules | Push logic into the domain model | +| **Wrong-layer validation** | Input format mixed with domain invariants | API validates shape; domain enforces invariants | diff --git a/plugins/dotnet-backend/skills/dotnet-backend/references/ef-migrations.md b/plugins/dotnet-backend/skills/dotnet-backend/references/ef-migrations.md new file mode 100644 index 0000000..75632ac --- /dev/null +++ b/plugins/dotnet-backend/skills/dotnet-backend/references/ef-migrations.md @@ -0,0 +1,578 @@ +# EF Core 9 Migration Best Practices (.NET 9) + +## 1. Migration Basics + +### Core Commands + +```bash +# Create new migration +dotnet ef migrations add --project + +# Remove last migration (if not applied) +dotnet ef migrations remove --project + +# Apply migrations to database +dotnet ef database update --project + +# Rollback to specific migration +dotnet ef database update --project + +# Generate SQL script +dotnet ef migrations script --project --output migration.sql + +# List all migrations +dotnet ef migrations list --project +``` + +### Naming Conventions + +```bash +# Good: Descriptive, action-oriented +dotnet ef migrations add AddUserEmailIndex +dotnet ef migrations add CreateProductsTable +dotnet ef migrations add UpdateOrderStatusEnum + +# Bad: Generic, unclear +dotnet ef migrations add Update1 +dotnet ef migrations add Changes +``` + +### When to Create New vs Modify Existing + +**Create new migration when:** +- Migration already applied to any environment (dev, staging, prod) +- Migration exists in shared branch (main, develop) +- Migration pushed to remote repository + +**Modify existing migration when:** +- Only in local development +- Not yet applied to any database +- Not shared with team + +```bash +# Safe to modify: remove and recreate +dotnet ef migrations remove +# Make model changes +dotnet ef migrations add AddUserEmailIndex +``` + +## 2. Team Workflow + +### Handling Merge Conflicts + +**ModelSnapshot conflicts are common:** + +```bash +# After pulling/merging, regenerate snapshot +dotnet ef migrations remove # Remove your local migration +git pull origin main +dotnet ef migrations add YourMigrationName # Recreate with updated snapshot +``` + +**Multiple developers creating migrations simultaneously:** + +```bash +# Developer A: 20240206120000_AddUserEmail.cs +# Developer B: 20240206120100_AddProductSku.cs + +# After merge, verify order +dotnet ef migrations list + +# If order is wrong, recreate migrations in correct sequence +``` + +### One Migration Per PR Rule + +```bash +# Good: Single focused migration +- PR #123: AddUserEmailIndex + - 20240206_AddUserEmailIndex.cs + - Updated ModelSnapshot + +# Bad: Multiple migrations +- PR #124: Multiple changes + - 20240206_AddUserEmail.cs + - 20240207_AddProductSku.cs + - 20240208_UpdateOrders.cs +``` + +### Migration Ordering in Branches + +```csharp +// Feature branch workflow +// 1. Create feature branch from main +git checkout -b feature/add-user-email + +// 2. Make model changes +public class User +{ + public string Email { get; set; } = string.Empty; +} + +// 3. Create migration +dotnet ef migrations add AddUserEmail + +// 4. Before PR, rebase on latest main +git fetch origin +git rebase origin/main + +// 5. If conflicts, regenerate migration +dotnet ef migrations remove +dotnet ef migrations add AddUserEmail +``` + +## 3. CI/CD Integration + +### Migration Bundles (Recommended for .NET 9) + +```bash +# Create migration bundle (self-contained executable) +dotnet ef migrations bundle --project MyApp.Infrastructure --output efbundle + +# Run in deployment pipeline +./efbundle --connection "Server=prod;Database=mydb;..." +``` + +### Idempotent SQL Scripts + +```bash +# Generate idempotent script (safe to run multiple times) +dotnet ef migrations script --idempotent --output migrations.sql --project MyApp.Infrastructure + +# Apply in CI/CD +sqlcmd -S server -d database -i migrations.sql +``` + +### Bundles vs Scripts Comparison + +| Feature | Migration Bundles | SQL Scripts | +|---------|------------------|-------------| +| Cross-platform | Yes | Database-specific | +| Runtime deps | .NET Runtime | Database client | +| Rollback | Built-in | Manual | +| Azure DevOps | Easy | Requires SQL task | +| Docker | Single file | Requires tooling | + +### Deployment Pipeline Example + +```yaml +# Azure DevOps pipeline +- task: DotNetCoreCLI@2 + displayName: 'Create EF Bundle' + inputs: + command: 'custom' + custom: 'ef' + arguments: 'migrations bundle --configuration Release --output $(Build.ArtifactStagingDirectory)/efbundle' + +- task: Bash@3 + displayName: 'Apply Migrations' + inputs: + targetType: 'inline' + script: | + chmod +x $(Pipeline.Workspace)/drop/efbundle + $(Pipeline.Workspace)/drop/efbundle --connection "$(ConnectionString)" +``` + +**Never run migrations in application startup:** + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +// WRONG: Don't do this in production +public static void Main(string[] args) +{ + var host = CreateHostBuilder(args).Build(); + + using (var scope = host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); // DANGEROUS in production + } + + host.Run(); +} + +// RIGHT: Run migrations in deployment pipeline +// Application startup should only verify migrations are current +``` + +## 4. Data Seeding + +### HasData for Static Reference Data + +```csharp +using Microsoft.EntityFrameworkCore; + +public class AppDbContext : DbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasData( + new OrderStatus { Id = 1, Name = "Pending" }, + new OrderStatus { Id = 2, Name = "Processing" }, + new OrderStatus { Id = 3, Name = "Completed" } + ); + } +} +``` + +### Custom Data Seeding Migrations + +```csharp +using Microsoft.EntityFrameworkCore.Migrations; + +// Create empty migration for data seeding +// dotnet ef migrations add SeedUserRoles + +// Edit migration manually +public partial class SeedUserRoles : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" + INSERT INTO Roles (Id, Name) VALUES + ('admin-guid', 'Administrator'), + ('user-guid', 'User'); + "); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" + DELETE FROM Roles WHERE Id IN ('admin-guid', 'user-guid'); + "); + } +} +``` + +### Environment-Specific Seeds + +```csharp +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; + +public static class DatabaseSeeder +{ + public static async Task SeedAsync(AppDbContext context, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + await SeedTestDataAsync(context); + } + + // Always seed reference data + await SeedReferenceDataAsync(context); + } + + private static async Task SeedTestDataAsync(AppDbContext context) + { + if (!await context.Users.AnyAsync()) + { + context.Users.AddRange( + new User { Email = "test1@example.com" }, + new User { Email = "test2@example.com" } + ); + await context.SaveChangesAsync(); + } + } +} +``` + +## 5. Advanced Patterns + +### Data Migrations (Transform Existing Data) + +```csharp +using Microsoft.EntityFrameworkCore.Migrations; + +// dotnet ef migrations add MigrateUserEmailToLowercase + +public partial class MigrateUserEmailToLowercase : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + // Step 1: Add new column + migrationBuilder.AddColumn( + name: "EmailNormalized", + table: "Users", + nullable: true); + + // Step 2: Migrate data + migrationBuilder.Sql(@" + UPDATE Users + SET EmailNormalized = LOWER(Email) + "); + + // Step 3: Make non-nullable + migrationBuilder.AlterColumn( + name: "EmailNormalized", + table: "Users", + nullable: false); + } +} +``` + +### Custom SQL for Performance + +```csharp +using Microsoft.EntityFrameworkCore.Migrations; + +public partial class AddUserEmailIndex : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + // PostgreSQL: CREATE INDEX CONCURRENTLY (non-blocking) + // SQL Server: CREATE INDEX ... WITH (ONLINE = ON) (Enterprise edition) + migrationBuilder.Sql( + ActiveProvider == "Npgsql.EntityFrameworkCore.PostgreSQL" + ? "CREATE INDEX CONCURRENTLY IX_Users_Email ON \"Users\" (\"Email\")" + : "CREATE NONCLUSTERED INDEX IX_Users_Email ON Users (Email)"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex("IX_Users_Email", "Users"); + } +} +``` + +### Expand-Contract Pattern for Breaking Changes + +```csharp +using Microsoft.EntityFrameworkCore.Migrations; + +// Phase 1: Expand - Add new column +// dotnet ef migrations add AddUserFullName + +public partial class AddUserFullName : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn("FullName", "Users", nullable: true); + + // Populate from existing data + migrationBuilder.Sql(@" + UPDATE Users + SET FullName = CONCAT(FirstName, ' ', LastName) + "); + } +} + +// Deploy application that writes to both old and new columns +// Wait for deployment... + +// Phase 2: Contract - Remove old columns +// dotnet ef migrations add RemoveUserNameColumns + +public partial class RemoveUserNameColumns : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn("FirstName", "Users"); + migrationBuilder.DropColumn("LastName", "Users"); + + migrationBuilder.AlterColumn( + "FullName", "Users", nullable: false); + } +} +``` + +### Column Renames Without Data Loss + +```csharp +using Microsoft.EntityFrameworkCore.Migrations; + +public partial class RenameUserEmailColumn : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Email", + table: "Users", + newName: "EmailAddress"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "EmailAddress", + table: "Users", + newName: "Email"); + } +} +``` + +## 6. Modular Monolith Considerations + +### Per-Module DbContext Setup + +```csharp +using Microsoft.EntityFrameworkCore; + +// Orders module +public class OrdersDbContext : DbContext +{ + public OrdersDbContext(DbContextOptions options) + : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("orders"); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly); + } +} + +// Products module +public class ProductsDbContext : DbContext +{ + public ProductsDbContext(DbContextOptions options) + : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("products"); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ProductsDbContext).Assembly); + } +} +``` + +### Independent Migration Histories + +```bash +# Create migrations per module +dotnet ef migrations add InitialCreate --context OrdersDbContext --project Orders.Infrastructure +dotnet ef migrations add InitialCreate --context ProductsDbContext --project Products.Infrastructure + +# Apply migrations independently +dotnet ef database update --context OrdersDbContext --project Orders.Infrastructure +dotnet ef database update --context ProductsDbContext --project Products.Infrastructure + +# Generate separate bundles +dotnet ef migrations bundle --context OrdersDbContext --output orders-bundle +dotnet ef migrations bundle --context ProductsDbContext --output products-bundle +``` + +### Schema Separation Benefits + +```csharp +// Tables are isolated by schema +// orders.Orders, orders.OrderItems +// products.Products, products.Categories + +// Migration history tables are separate +// orders.__EFMigrationsHistory +// products.__EFMigrationsHistory +``` + +## 7. Common Pitfalls + +### Startup Migrations (Don't Do This) + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +// WRONG: Multiple app instances = race conditions, locks, failures +public static void Main(string[] args) +{ + var host = CreateHostBuilder(args).Build(); + using (var scope = host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); // Bad in production + } + host.Run(); +} + +// RIGHT: Use deployment pipeline with migration bundles +// App only reads data, never modifies schema +``` + +### Table Locking During Migrations + +```csharp +using Microsoft.EntityFrameworkCore.Migrations; + +// WRONG: Locks entire table in production +migrationBuilder.AddColumn("Email", "Users", nullable: false); + +// RIGHT: Multi-phase approach +// Phase 1: Add nullable column +migrationBuilder.AddColumn("Email", "Users", nullable: true); + +// Phase 2: Backfill data (separate deployment) +migrationBuilder.Sql("UPDATE Users SET Email = LegacyEmail WHERE Email IS NULL"); + +// Phase 3: Make non-nullable (separate deployment) +migrationBuilder.AlterColumn("Email", "Users", nullable: false); +``` + +### Null to Non-Null Transitions + +```csharp +using Microsoft.EntityFrameworkCore.Migrations; + +// WRONG: Will fail if existing rows have NULL +migrationBuilder.AlterColumn( + "Email", "Users", nullable: false, oldNullable: true); + +// RIGHT: Three-step process +// Migration 1: Add column as nullable +migrationBuilder.AddColumn("Email", "Users", nullable: true); + +// Migration 2: Populate data +migrationBuilder.Sql("UPDATE Users SET Email = 'default@example.com' WHERE Email IS NULL"); + +// Migration 3: Make non-nullable +migrationBuilder.AlterColumn("Email", "Users", nullable: false); +``` + +### EnsureCreated vs Migrate + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +// WRONG: EnsureCreated bypasses migrations entirely +context.Database.EnsureCreated(); // Creates schema without migration history + +// WRONG: Mixing both +context.Database.EnsureCreated(); // Creates tables +context.Database.Migrate(); // Fails - tables already exist + +// RIGHT: Use Migrate for all environments +context.Database.Migrate(); // Respects migration history + +// RIGHT: Or for testing, use in-memory database +services.AddDbContext(options => + options.UseInMemoryDatabase("TestDb")); +``` + +### Managing Connection Strings + +```bash +# Development: User secrets +dotnet user-secrets init +dotnet user-secrets set "ConnectionStrings:Default" "Server=localhost;..." + +# CI/CD: Environment variables or Azure Key Vault +export ConnectionStrings__Default="Server=prod;..." + +# Bundle with connection string override +./efbundle --connection "$(CONNECTION_STRING)" +``` + +## Summary Checklist + +- Use migration bundles for deployment automation +- One migration per PR to avoid conflicts +- Never run migrations in application startup +- Use expand-contract for breaking changes +- Separate schemas for modular monoliths +- Test migrations on production-like data volumes +- Generate idempotent scripts for SQL-based deployments +- Always provide Down methods for rollback capability +- Use HasData only for static reference data +- Custom SQL for data transformations and performance-critical operations diff --git a/plugins/dotnet-backend/skills/dotnet-backend/references/error-handling.md b/plugins/dotnet-backend/skills/dotnet-backend/references/error-handling.md new file mode 100644 index 0000000..1fe95d7 --- /dev/null +++ b/plugins/dotnet-backend/skills/dotnet-backend/references/error-handling.md @@ -0,0 +1,321 @@ +# Error Handling Patterns - .NET 9 + +## 1. Exception Hierarchy + +Define a base exception and typed subclasses. The global middleware maps each type to an HTTP status code. + +```csharp +using System.Collections.ObjectModel; + +public abstract class AppException(string message) : Exception(message); + +public sealed class NotFoundException : AppException +{ + public NotFoundException(string entity, object key) + : base($"{entity} with key '{key}' was not found.") { } +} + +public sealed class ConflictException : AppException +{ + public ConflictException(string message) : base(message) { } +} + +public sealed class ValidationException : AppException +{ + public IReadOnlyDictionary Errors { get; } + + public ValidationException(IReadOnlyDictionary errors) + : base("One or more validation errors occurred.") + { + Errors = errors; + } +} + +public sealed class ForbiddenException : AppException +{ + public ForbiddenException(string? message = null) + : base(message ?? "You do not have permission to perform this action.") { } +} +``` + +## 2. ProblemDetails Middleware + +Implement `IExceptionHandler` (.NET 9) to map each exception type to a ProblemDetails response that follows RFC 9457. + +```csharp +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +public sealed class GlobalExceptionHandler : IExceptionHandler +{ + private readonly IProblemDetailsService _problemDetailsService; + private readonly ILogger _logger; + + public GlobalExceptionHandler( + IProblemDetailsService problemDetailsService, + ILogger logger) + { + _problemDetailsService = problemDetailsService; + _logger = logger; + } + + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + _logger.LogError(exception, "Unhandled exception: {Message}", exception.Message); + + var problemDetails = exception switch + { + NotFoundException ex => new ProblemDetails + { + Status = StatusCodes.Status404NotFound, + Title = "Resource Not Found", + Detail = ex.Message, + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.5" + }, + ConflictException ex => new ProblemDetails + { + Status = StatusCodes.Status409Conflict, + Title = "Conflict", + Detail = ex.Message, + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10" + }, + ValidationException ex => CreateValidationProblemDetails(ex), + ForbiddenException ex => new ProblemDetails + { + Status = StatusCodes.Status403Forbidden, + Title = "Forbidden", + Detail = ex.Message, + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.4" + }, + _ => new ProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Title = "Internal Server Error", + Detail = "An unexpected error occurred.", + Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1" + } + }; + + problemDetails.Instance = httpContext.Request.Path; + problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier; + + httpContext.Response.StatusCode = problemDetails.Status ?? 500; + + return await _problemDetailsService.TryWriteAsync( + new ProblemDetailsContext + { + HttpContext = httpContext, + ProblemDetails = problemDetails + }); + } + + private static ProblemDetails CreateValidationProblemDetails(ValidationException ex) + { + var problem = new ProblemDetails + { + Status = StatusCodes.Status422UnprocessableEntity, + Title = "Validation Failed", + Detail = ex.Message, + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.21" + }; + problem.Extensions["errors"] = ex.Errors; + return problem; + } +} +``` + +## 3. FluentValidation Integration + +A MediatR pipeline behavior that runs all registered validators before the handler executes. Validation failures are collected and thrown as a single `ValidationException`. + +```csharp +using System.Collections.ObjectModel; +using FluentValidation; +using MediatR; + +public sealed class ValidationBehavior + : IPipelineBehavior + where TRequest : notnull +{ + private readonly IEnumerable> _validators; + + public ValidationBehavior(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + if (!_validators.Any()) + return await next(); + + var context = new FluentValidation.ValidationContext(request); + + var results = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var errors = results + .SelectMany(r => r.Errors) + .Where(f => f is not null) + .GroupBy(f => f.PropertyName) + .ToDictionary( + g => g.Key, + g => g.Select(f => f.ErrorMessage).ToArray()); + + if (errors.Count > 0) + throw new ValidationException( + new ReadOnlyDictionary(errors)); + + return await next(); + } +} +``` + +## 4. Domain Invariant Enforcement + +Use a `Guard` utility with static methods to enforce domain rules inside entity constructors and methods. Failures throw domain-level exceptions that the middleware catches. + +```csharp +using System; + +public static class Guard +{ + public static string AgainstNullOrWhiteSpace(string? value, string paramName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException($"{paramName} must not be empty.", paramName); + return value; + } + + public static T AgainstNull(T? value, string paramName) where T : class + { + ArgumentNullException.ThrowIfNull(value, paramName); + return value; + } + + public static decimal AgainstNegativeOrZero(decimal value, string paramName) + { + if (value <= 0) + throw new ArgumentOutOfRangeException(paramName, $"{paramName} must be positive."); + return value; + } +} + +// Usage in an entity constructor: +public sealed class Order +{ + public Guid Id { get; } + public string CustomerEmail { get; } + public decimal Quantity { get; } + + public Order(Guid id, string customerEmail, decimal quantity) + { + Id = id == Guid.Empty ? Guid.NewGuid() : id; + CustomerEmail = Guard.AgainstNullOrWhiteSpace(customerEmail, nameof(customerEmail)); + Quantity = Guard.AgainstNegativeOrZero(quantity, nameof(quantity)); + } +} +``` + +## 5. Consistent API Error Responses + +Every error response follows the RFC 9457 ProblemDetails shape. Below are example JSON payloads for each error type. + +**404 Not Found** +```json +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5", + "title": "Resource Not Found", + "status": 404, + "detail": "Order with key '7a1c3e00-...' was not found.", + "instance": "/api/orders/7a1c3e00-...", + "traceId": "00-abc123-def456-01" +} +``` + +**422 Validation Failed** +```json +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.21", + "title": "Validation Failed", + "status": 422, + "detail": "One or more validation errors occurred.", + "instance": "/api/orders", + "traceId": "00-abc123-def456-01", + "errors": { + "CustomerEmail": ["CustomerEmail must not be empty."], + "Quantity": ["Quantity must be greater than zero."] + } +} +``` + +**409 Conflict** +```json +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.10", + "title": "Conflict", + "status": 409, + "detail": "An order with the same reference already exists.", + "instance": "/api/orders", + "traceId": "00-abc123-def456-01" +} +``` + +**403 Forbidden** +```json +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.4", + "title": "Forbidden", + "status": 403, + "detail": "You do not have permission to perform this action.", + "instance": "/api/orders/7a1c3e00-.../cancel", + "traceId": "00-abc123-def456-01" +} +``` + +## 6. Program.cs Setup + +Wire everything together in `Program.cs`. Order matters: `UseExceptionHandler` must appear before routing. + +```csharp +using FluentValidation; + +var builder = WebApplication.CreateBuilder(args); + +// Register ProblemDetails services (RFC 9457 support) +builder.Services.AddProblemDetails(); + +// Register the global exception handler +builder.Services.AddExceptionHandler(); + +// Register MediatR with the validation pipeline behavior +builder.Services.AddMediatR(cfg => +{ + cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); +}); + +// Register all FluentValidation validators from the assembly +builder.Services.AddValidatorsFromAssemblyContaining(); + +var app = builder.Build(); + +// Global exception handling middleware (must precede routing) +app.UseExceptionHandler(); + +// Return ProblemDetails for bare status codes (e.g. 401, 404 from framework) +app.UseStatusCodePages(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); +``` diff --git a/plugins/dotnet-backend/skills/dotnet-backend/references/observability.md b/plugins/dotnet-backend/skills/dotnet-backend/references/observability.md new file mode 100644 index 0000000..9a43ad1 --- /dev/null +++ b/plugins/dotnet-backend/skills/dotnet-backend/references/observability.md @@ -0,0 +1,289 @@ +# Observability Patterns -- .NET 9 + +## 1. Logging Decision Tree + +| Factor | Built-in (`Microsoft.Extensions.Logging`) | Serilog | +|---|---|---| +| **Startup complexity** | Zero config, included in Host | NuGet packages + `UseSerilog()` wire-up | +| **Structured logging** | Supported via message templates | First-class; richer destructuring (`@`) | +| **Source-generated perf** | `[LoggerMessage]` -- zero-alloc | No equivalent; uses `MessageTemplate` parse cache | +| **Sink ecosystem** | Console, Debug, EventSource, OTLP | 100+ sinks (Seq, Elasticsearch, async wrappers) | +| **Enrichment** | Manual via `BeginScope` | Built-in enrichers (Machine, Thread, Correlation) | +| **Filtering at runtime** | `appsettings.json` reload | `LoggingLevelSwitch` -- change without restart | +| **OpenTelemetry export** | `AddOpenTelemetry()` on `ILoggingBuilder` | `Serilog.Sinks.OpenTelemetry` or bridge via `ILogger` | +| **Recommendation** | Default choice for new .NET 9 services | Choose when you need sink variety or advanced enrichment | + +## 2. Built-in Microsoft.Extensions.Logging + +Use source-generated `[LoggerMessage]` for hot-path logging. The compiler emits zero-allocation code that skips string interpolation when the log level is disabled. + +```csharp +using Microsoft.Extensions.Logging; + +public static partial class LogMessages +{ + [LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} placed by {CustomerId}, total {Total}")] + public static partial void OrderPlaced(ILogger logger, Guid orderId, string customerId, decimal total); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Payment retry {Attempt} for order {OrderId}")] + public static partial void PaymentRetry(ILogger logger, int attempt, Guid orderId); + + [LoggerMessage(Level = LogLevel.Error, Message = "Order processing failed for {OrderId}")] + public static partial void OrderFailed(ILogger logger, Guid orderId, Exception exception); +} +``` + +Inject `ILogger` and use scopes to attach contextual properties to every log entry within a block: + +```csharp +using Microsoft.Extensions.Logging; + +public class OrderService(ILogger logger) +{ + public async Task ProcessAsync(Order order, CancellationToken ct) + { + using (logger.BeginScope(new Dictionary + { + ["OrderId"] = order.Id, + ["CustomerId"] = order.CustomerId + })) + { + LogMessages.OrderPlaced(logger, order.Id, order.CustomerId, order.Total); + // All logs inside this block carry OrderId and CustomerId. + } + } +} +``` + +## 3. Serilog Setup + +When you need async sinks, enrichment pipelines, or sinks not available in the built-in stack, wire Serilog as the provider. + +```csharp +using Serilog; +using Serilog.Events; +using Serilog.Formatting.Compact; + +// Program.cs +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) + .Enrich.FromLogContext() + .Enrich.WithMachineName() + .Enrich.WithThreadId() + .Enrich.WithProperty("Service", "OrderApi") + .WriteTo.Console(new RenderedCompactJsonFormatter()) + .WriteTo.Async(a => a.File( + path: "logs/order-api-.log", + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 14, + formatter: new RenderedCompactJsonFormatter())) + .CreateLogger(); + +var builder = WebApplication.CreateBuilder(args); +builder.Host.UseSerilog(); +``` + +Key packages for .NET 9: + +```text +Serilog.AspNetCore +Serilog.Enrichers.Environment +Serilog.Enrichers.Thread +Serilog.Formatting.Compact +Serilog.Sinks.Async +Serilog.Sinks.Console +Serilog.Sinks.File +``` + +Serilog bridges into `ILogger` so all framework and library logs flow through the same pipeline. No dual-logging. + +## 4. OpenTelemetry Integration + +Full setup covering traces, metrics, and logs exported via OTLP. Works with Jaeger, Grafana Tempo, Prometheus, or any OTLP-compatible backend. + +```csharp +// Program.cs +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +var builder = WebApplication.CreateBuilder(args); + +var serviceName = "OrderApi"; +var serviceVersion = "1.0.0"; + +var resourceBuilder = ResourceBuilder.CreateDefault() + .AddService(serviceName: serviceName, serviceVersion: serviceVersion); + +// --- Traces --- +builder.Services.AddOpenTelemetry() + .ConfigureResource(r => r.AddService(serviceName, serviceVersion)) + .WithTracing(tracing => + { + tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddEntityFrameworkCoreInstrumentation() + .AddSource("OrderApi.Commands") // custom ActivitySource name + .SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(0.1))) // 10% in prod + .AddOtlpExporter(); // defaults to http://localhost:4317 + }) + .WithMetrics(metrics => + { + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter("OrderApi.Metrics") // custom Meter name + .AddOtlpExporter(); + }); + +// --- Logs (built-in provider route) --- +builder.Logging.AddOpenTelemetry(logging => +{ + logging.SetResourceBuilder(resourceBuilder); + logging.IncludeScopes = true; + logging.IncludeFormattedMessage = true; + logging.AddOtlpExporter(); +}); +``` + +Custom instrumentation with the `Activity` API (the .NET native span): + +```csharp +using System.Diagnostics; +using System.Diagnostics.Metrics; + +public class OrderCommandHandler +{ + private static readonly ActivitySource Source = new("OrderApi.Commands"); + private static readonly Meter Meter = new("OrderApi.Metrics"); + private static readonly Counter OrdersCreated = Meter.CreateCounter("orders.created"); + + public async Task Handle(CreateOrderCommand cmd, CancellationToken ct) + { + using var activity = Source.StartActivity("CreateOrder", ActivityKind.Internal); + activity?.SetTag("order.customer_id", cmd.CustomerId); + + // ... business logic ... + + OrdersCreated.Add(1, new KeyValuePair("region", cmd.Region)); + activity?.SetTag("order.id", orderId.ToString()); + return orderId; + } +} +``` + +## 5. Correlation IDs + +Propagate a single correlation ID across HTTP boundaries, MediatR pipeline, and log output. + +```csharp +using System.Diagnostics; +using MediatR; +using Microsoft.Extensions.Logging; + +// Middleware -- reads or generates X-Correlation-Id +public class CorrelationIdMiddleware(RequestDelegate next) +{ + private const string Header = "X-Correlation-Id"; + + public async Task InvokeAsync(HttpContext context) + { + if (!context.Request.Headers.TryGetValue(Header, out var correlationId)) + { + correlationId = Guid.NewGuid().ToString(); + } + + context.Items["CorrelationId"] = correlationId.ToString(); + Activity.Current?.SetTag("correlation.id", correlationId!); + + using (context.RequestServices.GetRequiredService>() + .BeginScope(new Dictionary { ["CorrelationId"] = correlationId.ToString()! })) + { + context.Response.Headers[Header] = correlationId; + await next(context); + } + } +} + +// MediatR behavior -- pushes correlation into every handler's log scope +public class CorrelationBehavior( + IHttpContextAccessor accessor, + ILogger> logger) : IPipelineBehavior + where TReq : notnull +{ + public async Task Handle(TReq request, RequestHandlerDelegate next, CancellationToken ct) + { + var correlationId = accessor.HttpContext?.Items["CorrelationId"]?.ToString() ?? "N/A"; + using (logger.BeginScope(new Dictionary { ["CorrelationId"] = correlationId })) + { + return await next(); + } + } +} +``` + +Register in `Program.cs`: + +```csharp +app.UseMiddleware(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CorrelationBehavior<,>)); +``` + +## 6. Health Checks + +Separate liveness (process is running) from readiness (dependencies are reachable). Kubernetes probes map directly to these endpoints. + +```csharp +using Microsoft.Extensions.Diagnostics.HealthChecks; + +// Program.cs +builder.Services + .AddHealthChecks() + .AddDbContextCheck( + name: "database", + tags: ["readiness"]) + .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["liveness"]); + +app.MapHealthChecks("/health/live", new HealthCheckOptions +{ + Predicate = check => check.Tags.Contains("liveness") +}); + +app.MapHealthChecks("/health/ready", new HealthCheckOptions +{ + Predicate = check => check.Tags.Contains("readiness"), + ResponseWriter = WriteResponse +}); + +// Structured JSON response for readiness +static Task WriteResponse(HttpContext context, HealthReport report) +{ + context.Response.ContentType = "application/json"; + var result = new + { + status = report.Status.ToString(), + checks = report.Entries.Select(e => new + { + name = e.Key, + status = e.Value.Status.ToString(), + duration = e.Value.Duration.TotalMilliseconds + }) + }; + return context.Response.WriteAsJsonAsync(result); +} +``` + +Packages: + +```text +Microsoft.Extensions.Diagnostics.HealthChecks +Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore +``` + +--- + +**Trade-off summary**: Start with built-in logging and `[LoggerMessage]` source generation. Add Serilog only when you need its sink ecosystem or enrichment pipeline. Wire OpenTelemetry regardless of the logging provider -- traces and metrics are independent concerns and OTLP export is the same either way. diff --git a/plugins/dotnet-backend/skills/dotnet-backend/references/signalr-patterns.md b/plugins/dotnet-backend/skills/dotnet-backend/references/signalr-patterns.md new file mode 100644 index 0000000..9a0f629 --- /dev/null +++ b/plugins/dotnet-backend/skills/dotnet-backend/references/signalr-patterns.md @@ -0,0 +1,377 @@ +# SignalR Real-Time Communication Patterns - .NET 9 + +## Strongly-Typed Hub Design + +Define a client interface to get compile-time safety on server-to-client calls. Organize hub methods by domain concern and inject services through constructor DI. + +```csharp +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +// Contract for server-to-client messages +public interface INotificationClient +{ + Task ReceiveOrderUpdate(OrderStatusDto status); + Task ReceiveMessage(ChatMessageDto message); + Task UserJoined(string userId, string displayName); + Task UserLeft(string userId); +} + +// Strongly-typed hub with DI +public class NotificationHub : Hub +{ + private readonly IOrderService _orderService; + private readonly ILogger _logger; + + public NotificationHub(IOrderService orderService, ILogger logger) + { + _orderService = orderService; + _logger = logger; + } + + public override async Task OnConnectedAsync() + { + var userId = Context.UserIdentifier; + _logger.LogInformation("Client connected: {ConnectionId}, User: {UserId}", + Context.ConnectionId, Context.UserIdentifier); + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + _logger.LogInformation("User {UserId} disconnected", Context.UserIdentifier); + await base.OnDisconnectedAsync(exception); + } + + // Hub methods — keep focused, delegate to services + public async Task SendMessage(string groupName, string content) + { + var userId = Context.UserIdentifier ?? throw new HubException("Unauthenticated"); + var message = new ChatMessageDto(userId, content, DateTimeOffset.UtcNow); + await Clients.Group(groupName).ReceiveMessage(message); + } + + public async Task SubscribeToOrder(string orderId) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"order-{orderId}"); + } +} +``` + +Register the hub in `Program.cs`: + +```csharp +builder.Services.AddSignalR() + .AddMessagePackProtocol(); // optional binary protocol + +app.MapHub("/hubs/notifications"); +``` + +## Group Management + +Groups are the primary mechanism for scoping real-time messages to relevant clients. Connections can belong to multiple groups simultaneously. + +```csharp +// Add current connection to a group +await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant-{tenantId}"); + +// Remove from group +await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"tenant-{tenantId}"); + +// Send to group from inside the hub +await Clients.Group($"tenant-{tenantId}").ReceiveOrderUpdate(status); + +// Send to group excluding the caller +await Clients.GroupExcept($"tenant-{tenantId}", [Context.ConnectionId]) + .ReceiveMessage(message); + +// Send to a specific user (all their connections) +await Clients.User(userId).ReceiveOrderUpdate(status); +``` + +Assign groups during connection based on claims: + +```csharp +using System.Security.Claims; + +public override async Task OnConnectedAsync() +{ + var tenantId = Context.User?.FindFirst("tenant_id")?.Value; + if (tenantId is not null) + await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant-{tenantId}"); + + var roles = Context.User?.FindAll(ClaimTypes.Role).Select(c => c.Value) ?? []; + foreach (var role in roles) + await Groups.AddToGroupAsync(Context.ConnectionId, $"role-{role}"); + + await base.OnConnectedAsync(); +} +``` + +## Authentication + +WebSocket connections cannot send custom headers after the initial handshake, so JWT tokens are passed via the query string. Configure the authentication middleware to read from both the `Authorization` header and the `access_token` query parameter. + +```csharp +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = builder.Configuration["Jwt:Issuer"], + ValidateAudience = true, + ValidAudience = builder.Configuration["Jwt:Audience"], + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)) + }; + + // SignalR sends the token on the query string for WebSocket + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + var path = context.HttpContext.Request.Path; + if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) + { + context.Token = accessToken; + } + return Task.CompletedTask; + } + }; + }); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapHub("/hubs/notifications").RequireAuthorization(); +``` + +Access the authenticated user inside the hub: + +```csharp +public async Task SendMessage(string groupName, string content) +{ + var userId = Context.UserIdentifier; // populated from ClaimTypes.NameIdentifier + var tenantId = Context.User?.FindFirst("tenant_id")?.Value; + // ... +} +``` + +## Integration with MediatR + +Use `IHubContext` to push SignalR notifications from MediatR handlers. This keeps the hub thin and lets domain events drive real-time updates. + +```csharp +using MediatR; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +// Domain event +public record OrderStatusChangedEvent(string OrderId, string Status, string TenantId) : INotification; + +// MediatR handler that pushes to SignalR +public class OrderStatusChangedHandler : INotificationHandler +{ + private readonly IHubContext _hubContext; + private readonly ILogger _logger; + + public OrderStatusChangedHandler( + IHubContext hubContext, + ILogger logger) + { + _hubContext = hubContext; + _logger = logger; + } + + public async Task Handle(OrderStatusChangedEvent notification, CancellationToken ct) + { + _logger.LogInformation("Broadcasting order {OrderId} status: {Status}", + notification.OrderId, notification.Status); + + var dto = new OrderStatusDto(notification.OrderId, notification.Status); + + // Notify the order-specific group + await _hubContext.Clients + .Group($"order-{notification.OrderId}") + .ReceiveOrderUpdate(dto); + + // Notify the tenant group + await _hubContext.Clients + .Group($"tenant-{notification.TenantId}") + .ReceiveOrderUpdate(dto); + } +} +``` + +Publish the event from your application service or command handler: + +```csharp +await _mediator.Publish(new OrderStatusChangedEvent(order.Id, "Shipped", order.TenantId), ct); +``` + +## Client Patterns + +### JavaScript/TypeScript Connection Management + +```typescript +import { HubConnectionBuilder, HubConnectionState, LogLevel } from "@microsoft/signalr"; +import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack"; + +const connection = new HubConnectionBuilder() + .withUrl("/hubs/notifications", { + accessTokenFactory: () => getAccessToken(), + }) + .withHubProtocol(new MessagePackHubProtocol()) // binary, smaller payloads + .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) // custom retry delays + .configureLogging(LogLevel.Information) + .build(); + +connection.onreconnecting((error) => { + console.warn("Connection lost. Reconnecting...", error); + showReconnectingBanner(); +}); + +connection.onreconnected((connectionId) => { + console.info("Reconnected:", connectionId); + hideReconnectingBanner(); + resubscribeToGroups(); // re-join groups after reconnect +}); + +connection.onclose((error) => { + console.error("Connection closed permanently:", error); + showDisconnectedState(); +}); + +// Register handlers before starting +connection.on("ReceiveOrderUpdate", (status) => updateOrderUI(status)); +connection.on("ReceiveMessage", (message) => appendMessage(message)); + +async function start() { + if (connection.state === HubConnectionState.Disconnected) { + await connection.start(); + } +} + +start(); +``` + +### MessagePack Protocol + +MessagePack produces smaller payloads and faster serialization than JSON. Enable it on both server and client. + +Server: `builder.Services.AddSignalR().AddMessagePackProtocol();` +Client: pass `new MessagePackHubProtocol()` to `.withHubProtocol()` as shown above. + +## Scaling with Redis Backplane + +A single SignalR server keeps group and connection state in memory. When scaling to multiple instances, use the Redis backplane so messages reach all connected clients regardless of which server they are connected to. + +```csharp +using StackExchange.Redis; + +builder.Services.AddSignalR() + .AddStackExchangeRedis(builder.Configuration.GetConnectionString("Redis")!, options => + { + options.Configuration.ChannelPrefix = RedisChannel.Literal("NotificationHub"); + }); +``` + +Sticky sessions (affinity) are required for transports that use multiple HTTP requests (Long Polling, Server-Sent Events). WebSocket connections are persistent and do not strictly need affinity, but enabling it avoids issues during transport negotiation. + +Configure in your load balancer or Kubernetes ingress: + +```yaml +# NGINX Ingress annotation for sticky sessions +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + nginx.ingress.kubernetes.io/affinity: "cookie" + nginx.ingress.kubernetes.io/session-cookie-name: "SignalRAffinity" + nginx.ingress.kubernetes.io/session-cookie-hash: "sha1" +``` + +## Testing SignalR + +### Integration Testing Hubs + +Use `WebApplicationFactory` and the SignalR client to write end-to-end hub tests. + +```csharp +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.SignalR.Client; + +public class NotificationHubTests : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + public NotificationHubTests(WebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task SendMessage_BroadcastsToGroup() + { + var server = _factory.WithWebHostBuilder(b => { /* test overrides */ }); + var client = server.CreateClient(); + + var connection = new HubConnectionBuilder() + .WithUrl($"{client.BaseAddress}hubs/notifications", options => + { + options.HttpMessageHandlerFactory = _ => server.Server.CreateHandler(); + }) + .Build(); + + var received = new TaskCompletionSource(); + connection.On("ReceiveMessage", msg => received.SetResult(msg)); + + await connection.StartAsync(); + await connection.InvokeAsync("SubscribeToOrder", "order-123"); + await connection.InvokeAsync("SendMessage", "order-123", "Hello"); + + var message = await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal("Hello", message.Content); + + await connection.StopAsync(); + } +} +``` + +### Mocking IHubContext in Unit Tests + +When testing MediatR handlers or services that send SignalR messages, mock the hub context using NSubstitute. + +```csharp +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using NSubstitute; + +[Fact] +public async Task Handle_SendsNotificationToOrderGroup() +{ + var mockClients = Substitute.For>(); + var mockClient = Substitute.For(); + mockClients.Group(Arg.Any()).Returns(mockClient); + + var hubContext = Substitute.For>(); + hubContext.Clients.Returns(mockClients); + + var logger = Substitute.For>(); + + var handler = new OrderStatusChangedHandler(hubContext, logger); + + var evt = new OrderStatusChangedEvent("order-1", "Shipped", "tenant-abc"); + await handler.Handle(evt, CancellationToken.None); + + await mockClient.Received(1).ReceiveOrderUpdate(Arg.Is( + d => d.OrderId == "order-1" && d.Status == "Shipped")); +} +``` diff --git a/plugins/dotnet-backend/skills/dotnet-backend/references/testing-strategies.md b/plugins/dotnet-backend/skills/dotnet-backend/references/testing-strategies.md new file mode 100644 index 0000000..b7276d6 --- /dev/null +++ b/plugins/dotnet-backend/skills/dotnet-backend/references/testing-strategies.md @@ -0,0 +1,435 @@ +# Architecture-Specific Testing Strategies (.NET 9) + +## Testing Strategy by Architecture + +### Clean Architecture + +Emphasize **unit tests** for domain and application layers. The domain layer is pure C# with no +infrastructure dependencies, making it trivially testable. The application layer uses interfaces +(ports) that are easily substituted with NSubstitute. + +- **Domain Layer**: Direct unit tests, no mocks needed. Test value objects, entities, domain services, and domain events. +- **Application Layer**: Unit test MediatR handlers by mocking `DbContext` or interface dependencies via NSubstitute. +- **Infrastructure/API Layer**: Thin integration tests via `WebApplicationFactory` to verify wiring. +- **Ratio**: ~60% unit, ~30% integration, ~10% E2E. + +### Vertical Slice Architecture + +Emphasize **integration tests** that exercise the full slice through the HTTP pipeline. Each +feature is a self-contained vertical cut, so testing at the HTTP boundary gives the highest +confidence-to-effort ratio. + +- **Primary approach**: `WebApplicationFactory` + real database via Testcontainers + Respawn for state reset. +- **Unit tests only for**: Complex domain logic extracted into pure functions or domain models. +- **Ratio**: ~20% unit, ~70% integration, ~10% E2E. + +### Modular Monolith + +Test **within modules** using integration tests and **across modules** using contract tests. +Each module exposes a public API (typically via MediatR notifications or explicit contracts) +that neighboring modules depend on. + +- **Intra-module**: Full integration tests per module with isolated database schemas. +- **Cross-module**: Contract tests verifying published events and shared DTOs remain compatible. +- **Ratio**: ~30% unit, ~50% integration, ~20% contract/E2E. + +--- + +## Core Testing Stack + +| Package | Purpose | +|-----------------------|----------------------------------------------| +| `xUnit` | Test framework with parallel execution | +| `FluentAssertions` | Readable assertion syntax | +| `NSubstitute` | Mocking/stubbing interfaces | +| `Testcontainers` | Disposable PostgreSQL/SQL Server in Docker | +| `Respawn` | Fast database state reset between tests | +| `Bogus` | Realistic fake data generation | +| `WebApplicationFactory` | In-process HTTP testing without network overhead | + +--- + +## Code Examples + +### WebApplicationFactory with Testcontainers + +```csharp +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Testcontainers.PostgreSql; + +public class AppFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .WithDatabase("testdb") + .WithUsername("test") + .WithPassword("test") + .Build(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + // Remove the production DbContext registration + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + if (descriptor is not null) + services.Remove(descriptor); + + // Register test DbContext pointing at the container + services.AddDbContext(options => + options.UseNpgsql(_dbContainer.GetConnectionString())); + }); + } + + public async Task InitializeAsync() + { + await _dbContainer.StartAsync(); + + // Apply migrations once on startup + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + } + + public new async Task DisposeAsync() + { + await _dbContainer.DisposeAsync(); + } +} +``` + +### Respawn Checkpoint per Test Class + +```csharp +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Respawn; +using Respawn.Graph; + +public abstract class IntegrationTestBase : IClassFixture, IAsyncLifetime +{ + private readonly AppFactory _factory; + private Respawner _respawner = default!; + protected HttpClient Client { get; private set; } = default!; + + protected IntegrationTestBase(AppFactory factory) + { + _factory = factory; + } + + public async Task InitializeAsync() + { + Client = _factory.CreateClient(); + + // Build respawner against the running container + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var connection = db.Database.GetDbConnection(); + await connection.OpenAsync(); + + _respawner = await Respawner.CreateAsync(connection, new RespawnerOptions + { + DbAdapter = DbAdapter.Postgres, + SchemasToInclude = ["public"], + TablesToIgnore = [new Table("__EFMigrationsHistory")] + }); + } + + public async Task DisposeAsync() + { + // Reset database state after each test class + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var connection = db.Database.GetDbConnection(); + await connection.OpenAsync(); + await _respawner.ResetAsync(connection); + } + + protected async Task SeedAsync(T entity) where T : class + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Set().Add(entity); + await db.SaveChangesAsync(); + return entity; + } +} +``` + +### Domain Unit Test (Clean Architecture) + +```csharp +using FluentAssertions; +using Xunit; + +public class OrderTests +{ + [Fact] + public void AddItem_WithValidProduct_ShouldIncreaseTotal() + { + // Arrange + var order = Order.Create(customerId: Guid.NewGuid()); + var product = new ProductSnapshot("SKU-001", "Widget", price: 25.00m); + + // Act + order.AddItem(product, quantity: 3); + + // Assert + order.Items.Should().HaveCount(1); + order.Total.Should().Be(75.00m); + } + + [Fact] + public void AddItem_ExceedingMaxQuantity_ShouldThrowDomainException() + { + // Arrange + var order = Order.Create(customerId: Guid.NewGuid()); + var product = new ProductSnapshot("SKU-001", "Widget", price: 10.00m); + + // Act + var act = () => order.AddItem(product, quantity: 10_001); + + // Assert + act.Should().Throw() + .WithMessage("*exceeds maximum*"); + } +} +``` + +### MediatR Handler Unit Test with NSubstitute + +```csharp +using FluentAssertions; +using MediatR; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Xunit; + +public class CreateOrderHandlerTests +{ + private readonly AppDbContext _db; + private readonly IPublisher _publisher; + private readonly CreateOrderHandler _sut; + + public CreateOrderHandlerTests() + { + // NOTE: In-memory provider is acceptable for isolated handler unit tests. + // For integration tests, always use Testcontainers with a real database. + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + _db = new AppDbContext(options); + _publisher = Substitute.For(); + _sut = new CreateOrderHandler(_db, _publisher); + } + + [Fact] + public async Task Handle_ValidCommand_ShouldPersistOrderAndPublishEvent() + { + // Arrange + var command = new CreateOrderCommand( + CustomerId: Guid.NewGuid(), + Items: [new OrderItemDto("SKU-001", 2)]); + + // Act + var result = await _sut.Handle(command, CancellationToken.None); + + // Assert + result.OrderId.Should().NotBeEmpty(); + + var persisted = await _db.Orders.FindAsync(result.OrderId); + persisted.Should().NotBeNull(); + persisted!.Items.Should().HaveCount(1); + + await _publisher.Received(1).Publish( + Arg.Is(e => e.OrderId == result.OrderId), + Arg.Any()); + } +} +``` + +### Full Integration Test through HTTP Endpoint (Vertical Slice) + +```csharp +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +public class CreateOrderEndpointTests(AppFactory factory) : IntegrationTestBase(factory) +{ + [Fact] + public async Task POST_Orders_WithValidPayload_ShouldReturn201() + { + // Arrange + var customer = await SeedAsync(new CustomerFaker().Generate()); + + var payload = new + { + CustomerId = customer.Id, + Items = new[] { new { Sku = "SKU-001", Quantity = 2 } } + }; + + // Act + var response = await Client.PostAsJsonAsync("/api/orders", payload); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var body = await response.Content.ReadFromJsonAsync(); + body.Should().NotBeNull(); + body!.OrderId.Should().NotBeEmpty(); + + // Verify side effects in the real database + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var order = await db.Orders + .Include(o => o.Items) + .FirstOrDefaultAsync(o => o.Id == body.OrderId); + + order.Should().NotBeNull(); + order!.Items.Should().HaveCount(1); + } + + [Fact] + public async Task POST_Orders_WithEmptyItems_ShouldReturn400() + { + // Arrange + var payload = new { CustomerId = Guid.NewGuid(), Items = Array.Empty() }; + + // Act + var response = await Client.PostAsJsonAsync("/api/orders", payload); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } +} +``` + +### Bogus Data Builder Pattern + +```csharp +using Bogus; + +public sealed class CustomerFaker : Faker +{ + public CustomerFaker() + { + CustomInstantiator(f => Customer.Create( + name: f.Person.FullName, + email: f.Internet.Email())); + + // Deterministic seed for reproducible test runs + UseSeed(42); + } +} + +public sealed class OrderFaker : Faker +{ + public OrderFaker(Guid? customerId = null) + { + CustomInstantiator(f => + { + var order = Order.Create(customerId ?? Guid.NewGuid()); + var itemCount = f.Random.Int(1, 5); + for (var i = 0; i < itemCount; i++) + { + order.AddItem( + new ProductSnapshot( + f.Commerce.Ean13(), + f.Commerce.ProductName(), + decimal.Parse(f.Commerce.Price(1, 500))), + quantity: f.Random.Int(1, 10)); + } + return order; + }); + } +} +``` + +--- + +## Test Organization + +### Naming Convention + +``` +MethodUnderTest_Scenario_ExpectedBehavior +``` + +Examples: `AddItem_WithValidProduct_ShouldIncreaseTotal`, `Handle_DuplicateEmail_ShouldReturnConflict`. + +### Arrange/Act/Assert + +Every test method follows the three-section structure with explicit comment markers. Keep each +section focused: Arrange sets up state, Act performs exactly one action, Assert verifies outcomes. + +### Shared Fixtures with IAsyncLifetime + +Use `IClassFixture` for expensive resources shared across a test class (database containers, +HTTP clients). Use `IAsyncLifetime` for setup/teardown that requires async operations. Never share +mutable state between test methods -- use Respawn or fresh seeds instead. + +### Project Structure + +``` +tests/ + Unit/ + Domain/ + OrderTests.cs + Application/ + Handlers/ + CreateOrderHandlerTests.cs + Integration/ + Fixtures/ + AppFactory.cs + IntegrationTestBase.cs + Endpoints/ + CreateOrderEndpointTests.cs + Fakers/ + CustomerFaker.cs + OrderFaker.cs +``` + +--- + +## Common Pitfalls + +### 1. Testing Implementation Details + +Do not assert on internal method calls or private state. Test observable behavior: return values, +persisted state, published events, HTTP responses. If a refactor breaks your tests but not the +behavior, your tests are coupled to implementation. + +### 2. Over-Mocking EF Core + +Do not mock `DbSet` or `IQueryable`. EF Core's in-memory provider or a real database via +Testcontainers are both superior options. Mocked LINQ queries do not test actual SQL translation +and give false confidence. Use `InMemoryDatabase` for fast handler unit tests and Testcontainers +for integration tests that must verify query correctness. + +### 3. Shared Mutable State Between Tests + +Tests that depend on data seeded by other tests are fragile and order-dependent. Always use +Respawn to reset state, or seed required data within each test method. Never rely on test +execution order. + +### 4. Ignoring the Test Pyramid per Architecture + +Clean Architecture projects that skip domain unit tests and only write integration tests +waste CI time. Vertical Slice projects that mock everything instead of testing the full slice +miss wiring bugs. Match your testing strategy to your architecture. + +### 5. Not Testing Failure Paths + +Every endpoint and handler should have tests for validation failures, not-found cases, and +concurrency conflicts. Use `FluentAssertions` to verify error response shapes and status codes, +not just the happy path. diff --git a/plugins/dotnet-backend/skills/dotnet-backend/stacks/clean-architecture.md b/plugins/dotnet-backend/skills/dotnet-backend/stacks/clean-architecture.md new file mode 100644 index 0000000..c47f4ce --- /dev/null +++ b/plugins/dotnet-backend/skills/dotnet-backend/stacks/clean-architecture.md @@ -0,0 +1,1272 @@ +# Clean Architecture Stack + +## Overview + +Clean Architecture enforces strict dependency inversion through concentric layers where source code dependencies point inward. The domain sits at the center with zero outward dependencies, application logic orchestrates use cases, infrastructure adapts to external concerns, and the API layer handles HTTP transport. This stack uses .NET 9, MediatR for CQRS, EF Core as the primary data layer (no repository pattern), and FluentValidation in the MediatR pipeline. + +## When to Choose Clean Architecture + +**Best for:** +- Large teams (3+ developers) where strict boundaries prevent coupling drift +- Long-lived products (2+ year horizon) where maintainability outweighs initial velocity +- Domains with complex business rules that benefit from isolation +- Systems requiring independent deployability of layers +- Projects where multiple UI surfaces consume the same application logic + +**Avoid when:** +- Small CRUD applications with thin business logic (use Vertical Slice instead) +- Rapid prototyping or MVPs where speed to market is the primary constraint +- Solo developer or two-person teams where the ceremony adds overhead without benefit +- Microservices that are already small and focused on a single bounded context + +## Solution Structure + +``` +MyApp/ + MyApp.sln + src/ + MyApp.Domain/ + Entities/ + ValueObjects/ + Enums/ + Events/ + Exceptions/ + MyApp.Domain.csproj + MyApp.Application/ + Common/ + Behaviors/ + Interfaces/ + Models/ + Features/ + Orders/ + Commands/ + CreateOrder/ + CreateOrderCommand.cs + CreateOrderCommandHandler.cs + CreateOrderCommandValidator.cs + Queries/ + GetOrder/ + GetOrderQuery.cs + GetOrderQueryHandler.cs + OrderDto.cs + DependencyInjection.cs + MyApp.Application.csproj + MyApp.Infrastructure/ + Persistence/ + ApplicationDbContext.cs + Configurations/ + Interceptors/ + Migrations/ + Services/ + DapperQueries/ + DependencyInjection.cs + MyApp.Infrastructure.csproj + MyApp.WebApi/ + Endpoints/ + Controllers/ + Middleware/ + Filters/ + Program.cs + MyApp.WebApi.csproj + tests/ + MyApp.Domain.UnitTests/ + MyApp.Application.UnitTests/ + MyApp.Infrastructure.IntegrationTests/ + MyApp.WebApi.IntegrationTests/ +``` + +## Scaffolding Commands + +Run these commands from the root directory where you want the solution created. + +### Create Solution and Projects + +```bash +# Create solution +dotnet new sln -n MyApp +mkdir -p src tests + +# Create projects +dotnet new classlib -n MyApp.Domain -o src/MyApp.Domain -f net9.0 +dotnet new classlib -n MyApp.Application -o src/MyApp.Application -f net9.0 +dotnet new classlib -n MyApp.Infrastructure -o src/MyApp.Infrastructure -f net9.0 +dotnet new webapi -n MyApp.WebApi -o src/MyApp.WebApi -f net9.0 --use-controllers false + +# Create test projects +dotnet new xunit -n MyApp.Domain.UnitTests -o tests/MyApp.Domain.UnitTests -f net9.0 +dotnet new xunit -n MyApp.Application.UnitTests -o tests/MyApp.Application.UnitTests -f net9.0 +dotnet new xunit -n MyApp.Infrastructure.IntegrationTests -o tests/MyApp.Infrastructure.IntegrationTests -f net9.0 +dotnet new xunit -n MyApp.WebApi.IntegrationTests -o tests/MyApp.WebApi.IntegrationTests -f net9.0 + +# Add all projects to solution +dotnet sln add src/MyApp.Domain/MyApp.Domain.csproj +dotnet sln add src/MyApp.Application/MyApp.Application.csproj +dotnet sln add src/MyApp.Infrastructure/MyApp.Infrastructure.csproj +dotnet sln add src/MyApp.WebApi/MyApp.WebApi.csproj +dotnet sln add tests/MyApp.Domain.UnitTests/MyApp.Domain.UnitTests.csproj +dotnet sln add tests/MyApp.Application.UnitTests/MyApp.Application.UnitTests.csproj +dotnet sln add tests/MyApp.Infrastructure.IntegrationTests/MyApp.Infrastructure.IntegrationTests.csproj +dotnet sln add tests/MyApp.WebApi.IntegrationTests/MyApp.WebApi.IntegrationTests.csproj +``` + +### Project References (Dependency Rule) + +Dependencies always point inward. Domain has zero references. Nothing ever references WebApi. + +```bash +# Application depends on Domain +dotnet add src/MyApp.Application reference src/MyApp.Domain + +# Infrastructure depends on Application and Domain +dotnet add src/MyApp.Infrastructure reference src/MyApp.Application +dotnet add src/MyApp.Infrastructure reference src/MyApp.Domain + +# WebApi depends on Application and Infrastructure (for DI wiring only) +dotnet add src/MyApp.WebApi reference src/MyApp.Application +dotnet add src/MyApp.WebApi reference src/MyApp.Infrastructure + +# Test project references +dotnet add tests/MyApp.Domain.UnitTests reference src/MyApp.Domain +dotnet add tests/MyApp.Application.UnitTests reference src/MyApp.Application +dotnet add tests/MyApp.Application.UnitTests reference src/MyApp.Domain +dotnet add tests/MyApp.Infrastructure.IntegrationTests reference src/MyApp.Infrastructure +dotnet add tests/MyApp.Infrastructure.IntegrationTests reference src/MyApp.Application +dotnet add tests/MyApp.WebApi.IntegrationTests reference src/MyApp.WebApi +``` + +### Package Installations + +```bash +# Domain: zero NuGet packages (pure C#, no framework dependencies) + +# Application +dotnet add src/MyApp.Application package MediatR +dotnet add src/MyApp.Application package FluentValidation +dotnet add src/MyApp.Application package FluentValidation.DependencyInjectionExtensions + +# Infrastructure +dotnet add src/MyApp.Infrastructure package Microsoft.EntityFrameworkCore +dotnet add src/MyApp.Infrastructure package Microsoft.EntityFrameworkCore.SqlServer +dotnet add src/MyApp.Infrastructure package Microsoft.EntityFrameworkCore.Tools +dotnet add src/MyApp.Infrastructure package Dapper +dotnet add src/MyApp.Infrastructure package Microsoft.Data.SqlClient +dotnet add src/MyApp.Infrastructure package Microsoft.Extensions.Caching.StackExchangeRedis +dotnet add src/MyApp.Infrastructure package Microsoft.Extensions.Http.Resilience + +# WebApi +dotnet add src/MyApp.WebApi package Asp.Versioning.Http +dotnet add src/MyApp.WebApi package Asp.Versioning.Mvc.ApiExplorer +dotnet add src/MyApp.WebApi package Microsoft.AspNetCore.OpenApi +dotnet add src/MyApp.WebApi package Swashbuckle.AspNetCore + +# Test projects +dotnet add tests/MyApp.Application.UnitTests package NSubstitute +dotnet add tests/MyApp.Application.UnitTests package FluentAssertions +dotnet add tests/MyApp.Application.UnitTests package AutoFixture +dotnet add tests/MyApp.Infrastructure.IntegrationTests package Testcontainers.MsSql +dotnet add tests/MyApp.Infrastructure.IntegrationTests package Microsoft.EntityFrameworkCore.InMemory +dotnet add tests/MyApp.Infrastructure.IntegrationTests package FluentAssertions +dotnet add tests/MyApp.WebApi.IntegrationTests package Microsoft.AspNetCore.Mvc.Testing +dotnet add tests/MyApp.WebApi.IntegrationTests package FluentAssertions +dotnet add tests/MyApp.WebApi.IntegrationTests package Testcontainers.MsSql +``` + +### Create Folder Structure + +```bash +# Domain +mkdir -p src/MyApp.Domain/{Entities,ValueObjects,Enums,Events,Exceptions} + +# Application +mkdir -p src/MyApp.Application/Common/{Behaviors,Interfaces,Models} +mkdir -p src/MyApp.Application/Features + +# Infrastructure +mkdir -p src/MyApp.Infrastructure/Persistence/{Configurations,Interceptors,Migrations} +mkdir -p src/MyApp.Infrastructure/{Services,DapperQueries} + +# WebApi +mkdir -p src/MyApp.WebApi/{Endpoints,Controllers,Middleware,Filters} +``` + +## Code Examples + +### Domain Layer: Entity with Domain Events + +The domain layer contains pure C# with no framework dependencies. Entities enforce their own invariants. + +```csharp +// src/MyApp.Domain/Entities/Order.cs +using MediatR; +using MyApp.Domain.Enums; +using MyApp.Domain.Events; +using MyApp.Domain.Exceptions; + +namespace MyApp.Domain.Entities; + +public sealed class Order +{ + private readonly List _items = []; + + public Guid Id { get; private set; } + public string CustomerEmail { get; private set; } = default!; + public OrderStatus Status { get; private set; } + public DateTime CreatedAt { get; private set; } + public IReadOnlyCollection Items => _items.AsReadOnly(); + public decimal TotalAmount => _items.Sum(i => i.Price * i.Quantity); + + private readonly List _domainEvents = []; + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + private Order() { } // EF Core constructor + + public static Order Create(string customerEmail) + { + if (string.IsNullOrWhiteSpace(customerEmail)) + throw new DomainException("Customer email is required."); + + var order = new Order + { + Id = Guid.NewGuid(), + CustomerEmail = customerEmail, + Status = OrderStatus.Draft, + CreatedAt = DateTime.UtcNow + }; + + order._domainEvents.Add(new OrderCreatedEvent(order.Id)); + return order; + } + + public void AddItem(string productName, decimal price, int quantity) + { + if (Status != OrderStatus.Draft) + throw new DomainException("Can only add items to draft orders."); + if (quantity <= 0) + throw new DomainException("Quantity must be positive."); + + _items.Add(new OrderItem(productName, price, quantity)); + } + + public void Submit() + { + if (_items.Count == 0) + throw new DomainException("Cannot submit an empty order."); + + Status = OrderStatus.Submitted; + _domainEvents.Add(new OrderSubmittedEvent(Id, TotalAmount)); + } + + public void ClearDomainEvents() => _domainEvents.Clear(); +} +``` + +```csharp +// src/MyApp.Domain/Entities/OrderItem.cs +namespace MyApp.Domain.Entities; + +public sealed class OrderItem +{ + public Guid Id { get; private set; } + public string ProductName { get; private set; } = default!; + public decimal Price { get; private set; } + public int Quantity { get; private set; } + + private OrderItem() { } // EF Core + + public OrderItem(string productName, decimal price, int quantity) + { + Id = Guid.NewGuid(); + ProductName = productName; + Price = price; + Quantity = quantity; + } +} +``` + +```csharp +// src/MyApp.Domain/Enums/OrderStatus.cs +namespace MyApp.Domain.Enums; + +public enum OrderStatus +{ + Draft, + Submitted, + Confirmed, + Shipped, + Delivered, + Cancelled +} +``` + +```csharp +// src/MyApp.Domain/Events/OrderCreatedEvent.cs +using MediatR; + +namespace MyApp.Domain.Events; + +public sealed record OrderCreatedEvent(Guid OrderId) : INotification; +``` + +```csharp +// src/MyApp.Domain/Events/OrderSubmittedEvent.cs +using MediatR; + +namespace MyApp.Domain.Events; + +public sealed record OrderSubmittedEvent(Guid OrderId, decimal TotalAmount) : INotification; +``` + +```csharp +// src/MyApp.Domain/Exceptions/AppException.cs +namespace MyApp.Domain.Exceptions; + +public abstract class AppException : Exception +{ + protected AppException(string message) : base(message) { } +} + +// src/MyApp.Domain/Exceptions/DomainException.cs +namespace MyApp.Domain.Exceptions; + +public sealed class DomainException : AppException +{ + public DomainException(string message) : base(message) { } +} +``` + +### Application Layer: CQRS with MediatR + +Commands mutate state. Queries read state. Handlers orchestrate the work. + +```csharp +// src/MyApp.Application/Common/Interfaces/IApplicationDbContext.cs +using Microsoft.EntityFrameworkCore; +using MyApp.Domain.Entities; + +namespace MyApp.Application.Common.Interfaces; + +public interface IApplicationDbContext +{ + DbSet Orders { get; } + DbSet OrderItems { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} +``` + +```csharp +// src/MyApp.Application/Features/Orders/Commands/CreateOrder/CreateOrderCommand.cs +using MediatR; + +namespace MyApp.Application.Features.Orders.Commands.CreateOrder; + +public sealed record CreateOrderCommand( + string CustomerEmail, + List Items +) : IRequest; + +public sealed record OrderItemDto( + string ProductName, + decimal Price, + int Quantity +); +``` + +```csharp +// src/MyApp.Application/Features/Orders/Commands/CreateOrder/CreateOrderCommandValidator.cs +using FluentValidation; + +namespace MyApp.Application.Features.Orders.Commands.CreateOrder; + +public sealed class CreateOrderCommandValidator : AbstractValidator +{ + public CreateOrderCommandValidator() + { + RuleFor(x => x.CustomerEmail) + .NotEmpty() + .EmailAddress(); + + RuleFor(x => x.Items) + .NotEmpty() + .WithMessage("Order must contain at least one item."); + + RuleForEach(x => x.Items).ChildRules(item => + { + item.RuleFor(i => i.ProductName).NotEmpty(); + item.RuleFor(i => i.Price).GreaterThan(0); + item.RuleFor(i => i.Quantity).GreaterThan(0); + }); + } +} +``` + +```csharp +// src/MyApp.Application/Features/Orders/Commands/CreateOrder/CreateOrderCommandHandler.cs +using MediatR; +using MyApp.Application.Common.Interfaces; +using MyApp.Domain.Entities; + +namespace MyApp.Application.Features.Orders.Commands.CreateOrder; + +public sealed class CreateOrderCommandHandler( + IApplicationDbContext dbContext +) : IRequestHandler +{ + public async Task Handle( + CreateOrderCommand request, + CancellationToken cancellationToken) + { + var order = Order.Create(request.CustomerEmail); + + foreach (var item in request.Items) + { + order.AddItem(item.ProductName, item.Price, item.Quantity); + } + + order.Submit(); + + dbContext.Orders.Add(order); + await dbContext.SaveChangesAsync(cancellationToken); + + return order.Id; + } +} +``` + +```csharp +// src/MyApp.Application/Features/Orders/Queries/GetOrder/GetOrderQuery.cs +using MediatR; +using MyApp.Application.Features.Orders.Commands.CreateOrder; + +namespace MyApp.Application.Features.Orders.Queries.GetOrder; + +public sealed record GetOrderQuery(Guid OrderId) : IRequest; + +public sealed record OrderDetailDto( + Guid Id, + string CustomerEmail, + string Status, + decimal TotalAmount, + DateTime CreatedAt, + List Items +); +``` + +```csharp +// src/MyApp.Application/Features/Orders/Queries/GetOrder/GetOrderQueryHandler.cs +using MediatR; +using Microsoft.EntityFrameworkCore; +using MyApp.Application.Common.Interfaces; +using MyApp.Application.Features.Orders.Commands.CreateOrder; + +namespace MyApp.Application.Features.Orders.Queries.GetOrder; + +public sealed class GetOrderQueryHandler( + IApplicationDbContext dbContext +) : IRequestHandler +{ + public async Task Handle( + GetOrderQuery request, + CancellationToken cancellationToken) + { + return await dbContext.Orders + .Where(o => o.Id == request.OrderId) + .Select(o => new OrderDetailDto( + o.Id, + o.CustomerEmail, + o.Status.ToString(), + o.TotalAmount, + o.CreatedAt, + o.Items.Select(i => new OrderItemDto( + i.ProductName, i.Price, i.Quantity + )).ToList() + )) + .FirstOrDefaultAsync(cancellationToken); + } +} +``` + +### FluentValidation Pipeline Behavior + +This MediatR pipeline behavior intercepts every request and runs matching validators before the handler executes. Validation failures throw a custom `ValidationException` that the exception middleware converts to ProblemDetails. + +```csharp +// src/MyApp.Application/Common/Behaviors/ValidationBehavior.cs +using FluentValidation; +using MediatR; + +namespace MyApp.Application.Common.Behaviors; + +public sealed class ValidationBehavior( + IEnumerable> validators +) : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + if (!validators.Any()) + return await next(cancellationToken); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f is not null) + .ToList(); + + if (failures.Count != 0) + throw new Application.Common.Exceptions.ValidationException(failures); + + return await next(cancellationToken); + } +} +``` + +```csharp +// src/MyApp.Application/Common/Exceptions/ValidationException.cs +using FluentValidation.Results; +using MyApp.Domain.Exceptions; + +namespace MyApp.Application.Common.Exceptions; + +public sealed class ValidationException : AppException +{ + public IDictionary Errors { get; } + + public ValidationException(IEnumerable failures) + : base("One or more validation failures occurred.") + { + Errors = failures + .GroupBy(e => e.PropertyName, e => e.ErrorMessage) + .ToDictionary(g => g.Key, g => g.ToArray()); + } +} +``` + +```csharp +// src/MyApp.Application/Common/Exceptions/NotFoundException.cs +using MyApp.Domain.Exceptions; + +namespace MyApp.Application.Common.Exceptions; + +public sealed class NotFoundException : AppException +{ + public NotFoundException(string entityName, object key) + : base($"Entity \"{entityName}\" ({key}) was not found.") { } +} +``` + +### Application DI Registration + +```csharp +// src/MyApp.Application/DependencyInjection.cs +using System.Reflection; +using FluentValidation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using MyApp.Application.Common.Behaviors; + +namespace MyApp.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + var assembly = Assembly.GetExecutingAssembly(); + + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(assembly); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); + }); + + services.AddValidatorsFromAssembly(assembly); + + return services; + } +} +``` + +### Infrastructure Layer: EF Core (No Repository Pattern) + +DbContext IS the abstraction. It implements the application-layer interface directly. No wrapping repositories. + +```csharp +// src/MyApp.Infrastructure/Persistence/ApplicationDbContext.cs +using Microsoft.EntityFrameworkCore; +using MyApp.Application.Common.Interfaces; +using MyApp.Domain.Entities; + +namespace MyApp.Infrastructure.Persistence; + +public sealed class ApplicationDbContext( + DbContextOptions options +) : DbContext(options), IApplicationDbContext +{ + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly( + typeof(ApplicationDbContext).Assembly); + } +} +``` + +```csharp +// src/MyApp.Infrastructure/Persistence/Configurations/OrderConfiguration.cs +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MyApp.Domain.Entities; + +namespace MyApp.Infrastructure.Persistence.Configurations; + +public sealed class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(o => o.Id); + + builder.Property(o => o.CustomerEmail) + .HasMaxLength(256) + .IsRequired(); + + builder.Property(o => o.Status) + .HasConversion() + .HasMaxLength(50); + + builder.HasMany(o => o.Items) + .WithOne() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade); + + // Ignore domain events — they are not persisted + builder.Ignore(o => o.DomainEvents); + + builder.HasIndex(o => o.CustomerEmail); + builder.HasIndex(o => o.Status); + } +} +``` + +```csharp +// src/MyApp.Infrastructure/Persistence/Configurations/OrderItemConfiguration.cs +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MyApp.Domain.Entities; + +namespace MyApp.Infrastructure.Persistence.Configurations; + +public sealed class OrderItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(i => i.Id); + builder.Property(i => i.ProductName).HasMaxLength(200).IsRequired(); + builder.Property(i => i.Price).HasPrecision(18, 2); + builder.Property(i => i.Quantity).IsRequired(); + } +} +``` + +### Infrastructure: Dapper for Performance-Critical Queries + +Use Dapper alongside EF Core when you need raw SQL performance for read-heavy or reporting scenarios. + +```csharp +// src/MyApp.Infrastructure/DapperQueries/OrderSummaryQuery.cs +using Dapper; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; + +namespace MyApp.Infrastructure.DapperQueries; + +public sealed record OrderSummaryResult( + Guid OrderId, + string CustomerEmail, + decimal TotalAmount, + int ItemCount, + DateTime CreatedAt +); + +public sealed class OrderSummaryQuery(IConfiguration configuration) +{ + public async Task> GetRecentOrdersAsync( + int count, + CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT TOP (@Count) + o.Id AS OrderId, + o.CustomerEmail, + SUM(oi.Price * oi.Quantity) AS TotalAmount, + COUNT(oi.Id) AS ItemCount, + o.CreatedAt + FROM Orders o + LEFT JOIN OrderItems oi ON oi.OrderId = o.Id + GROUP BY o.Id, o.CustomerEmail, o.CreatedAt + ORDER BY o.CreatedAt DESC + """; + + await using var connection = new SqlConnection( + configuration.GetConnectionString("Default")); + + return await connection.QueryAsync( + new CommandDefinition(sql, new { Count = count }, cancellationToken: cancellationToken)); + } +} +``` + +### Infrastructure DI Registration + +```csharp +// src/MyApp.Infrastructure/DependencyInjection.cs +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MyApp.Application.Common.Interfaces; +using MyApp.Infrastructure.DapperQueries; +using MyApp.Infrastructure.Persistence; + +namespace MyApp.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddDbContext(options => + options.UseSqlServer( + configuration.GetConnectionString("Default"), + b => b.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName))); + + services.AddScoped(provider => + provider.GetRequiredService()); + + // Dapper queries + services.AddScoped(); + + return services; + } +} +``` + +### WebApi Layer: ProblemDetails Exception Middleware + +RFC 9457 ProblemDetails provides a standard, machine-readable error format. Map each custom exception to the appropriate HTTP status code. + +```csharp +// src/MyApp.WebApi/Middleware/ExceptionHandlingMiddleware.cs +using Microsoft.AspNetCore.Mvc; +using MyApp.Application.Common.Exceptions; +using MyApp.Domain.Exceptions; + +namespace MyApp.WebApi.Middleware; + +public sealed class ExceptionHandlingMiddleware( + RequestDelegate next, + ILogger logger) +{ + public async Task InvokeAsync(HttpContext httpContext) + { + try + { + await next(httpContext); + } + catch (Exception ex) + { + await HandleExceptionAsync(httpContext, ex); + } + } + + private async Task HandleExceptionAsync(HttpContext httpContext, Exception exception) + { + logger.LogError(exception, "Unhandled exception: {Message}", exception.Message); + + var problemDetails = exception switch + { + ValidationException validationEx => new ValidationProblemDetails(validationEx.Errors) + { + Status = StatusCodes.Status422UnprocessableEntity, + Title = "Validation Failed", + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1" + }, + NotFoundException => new ProblemDetails + { + Status = StatusCodes.Status404NotFound, + Title = "Not Found", + Detail = exception.Message, + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.5" + }, + DomainException => new ProblemDetails + { + Status = StatusCodes.Status409Conflict, + Title = "Domain Rule Violation", + Detail = exception.Message, + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10" + }, + _ => new ProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Title = "Internal Server Error", + Detail = "An unexpected error occurred.", + Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1" + } + }; + + httpContext.Response.StatusCode = problemDetails.Status ?? 500; + await httpContext.Response.WriteAsJsonAsync(problemDetails); + } +} +``` + +### WebApi Layer: Minimal API Endpoints + +Minimal APIs are the recommended approach for new .NET 9 projects. Group endpoints by feature using extension methods. + +```csharp +// src/MyApp.WebApi/Endpoints/OrderEndpoints.cs +using MediatR; +using MyApp.Application.Features.Orders.Commands.CreateOrder; +using MyApp.Application.Features.Orders.Queries.GetOrder; + +namespace MyApp.WebApi.Endpoints; + +public static class OrderEndpoints +{ + public static RouteGroupBuilder MapOrderEndpoints(this IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/api/v{version:apiVersion}/orders") + .WithTags("Orders") + .WithOpenApi(); + + group.MapPost("/", async (CreateOrderCommand command, ISender sender) => + { + var orderId = await sender.Send(command); + return Results.Created($"/api/v1/orders/{orderId}", new { id = orderId }); + }) + .WithName("CreateOrder") + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity); + + group.MapGet("/{id:guid}", async (Guid id, ISender sender) => + { + var order = await sender.Send(new GetOrderQuery(id)); + return order is not null ? Results.Ok(order) : Results.NotFound(); + }) + .WithName("GetOrder") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound); + + return group; + } +} +``` + +### WebApi Layer: Controller Alternative + +Use controllers when the team prefers convention-based routing, needs complex model binding, or has an existing controller-based codebase. + +```csharp +// src/MyApp.WebApi/Controllers/OrdersController.cs +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using MyApp.Application.Features.Orders.Commands.CreateOrder; +using MyApp.Application.Features.Orders.Queries.GetOrder; + +namespace MyApp.WebApi.Controllers; + +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/[controller]")] +public sealed class OrdersController(ISender sender) : ControllerBase +{ + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Create( + CreateOrderCommand command, + CancellationToken cancellationToken) + { + var orderId = await sender.Send(command, cancellationToken); + return CreatedAtAction(nameof(Get), new { id = orderId }, new { id = orderId }); + } + + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(OrderDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Get( + Guid id, + CancellationToken cancellationToken) + { + var order = await sender.Send(new GetOrderQuery(id), cancellationToken); + return order is not null ? Ok(order) : NotFound(); + } +} +``` + +**Minimal APIs vs Controllers trade-offs:** + +| Concern | Minimal APIs | Controllers | +|---------|-------------|-------------| +| Performance | Slightly faster (less middleware) | Negligible difference in practice | +| Discoverability | Explicit registration required | Convention-based automatic discovery | +| Testing | Test endpoint delegates directly | Well-established testing patterns | +| Model binding | Manual for complex scenarios | Rich attribute-based binding | +| Filters | Endpoint filters (newer API) | Action filters (mature ecosystem) | +| Team familiarity | Newer pattern | Familiar to most .NET developers | + +### Program.cs Setup + +```csharp +// src/MyApp.WebApi/Program.cs +using Asp.Versioning; +using MyApp.Application; +using MyApp.Infrastructure; +using MyApp.WebApi.Endpoints; +using MyApp.WebApi.Middleware; + +var builder = WebApplication.CreateBuilder(args); + +// Layer registration via extension methods +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); + +// API versioning +builder.Services.AddApiVersioning(options => +{ + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + options.ApiVersionReader = new UrlSegmentApiVersionReader(); +}) +.AddApiExplorer(options => +{ + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; +}); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddProblemDetails(); + +var app = builder.Build(); + +// Middleware pipeline +app.UseMiddleware(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +// Map endpoints +var versionSet = app.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1, 0)) + .Build(); + +app.MapOrderEndpoints() + .WithApiVersionSet(versionSet) + .MapToApiVersion(new ApiVersion(1, 0)); + +app.Run(); + +// Required for WebApplicationFactory in integration tests +public partial class Program; +``` + +## Testing Strategy + +Clean Architecture yields the highest return on unit tests because domain and application layers are pure logic with clear boundaries. + +### Domain Unit Tests (Pure Logic, No Mocks) + +Domain tests validate business rules. They require zero mocking because the domain has no dependencies. + +```csharp +// tests/MyApp.Domain.UnitTests/Entities/OrderTests.cs +using MyApp.Domain.Entities; +using MyApp.Domain.Enums; +using MyApp.Domain.Exceptions; + +namespace MyApp.Domain.UnitTests.Entities; + +public sealed class OrderTests +{ + [Fact] + public void Create_WithValidEmail_ShouldSetDraftStatus() + { + var order = Order.Create("test@example.com"); + + Assert.Equal(OrderStatus.Draft, order.Status); + Assert.Single(order.DomainEvents); + } + + [Fact] + public void Submit_WithNoItems_ShouldThrowDomainException() + { + var order = Order.Create("test@example.com"); + + var exception = Assert.Throws(() => order.Submit()); + Assert.Equal("Cannot submit an empty order.", exception.Message); + } + + [Fact] + public void AddItem_ToSubmittedOrder_ShouldThrowDomainException() + { + var order = Order.Create("test@example.com"); + order.AddItem("Widget", 10.00m, 1); + order.Submit(); + + Assert.Throws(() => + order.AddItem("Gadget", 5.00m, 2)); + } + + [Fact] + public void TotalAmount_ShouldSumAllItems() + { + var order = Order.Create("test@example.com"); + order.AddItem("Widget", 10.00m, 2); + order.AddItem("Gadget", 5.00m, 3); + + Assert.Equal(35.00m, order.TotalAmount); + } +} +``` + +### Application Unit Tests (Mock IApplicationDbContext) + +Handler tests mock the DbContext interface. Validate that handlers orchestrate correctly. + +```csharp +// tests/MyApp.Application.UnitTests/Features/Orders/CreateOrderCommandHandlerTests.cs +using NSubstitute; +using MyApp.Application.Common.Interfaces; +using MyApp.Application.Features.Orders.Commands.CreateOrder; +using MyApp.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MyApp.Application.UnitTests.Features.Orders; + +public sealed class CreateOrderCommandHandlerTests +{ + private readonly IApplicationDbContext _dbContext = Substitute.For(); + private readonly CreateOrderCommandHandler _handler; + + public CreateOrderCommandHandlerTests() + { + _dbContext.Orders.Returns(Substitute.For>()); + _handler = new CreateOrderCommandHandler(_dbContext); + } + + [Fact] + public async Task Handle_WithValidCommand_ShouldReturnOrderId() + { + var command = new CreateOrderCommand( + "test@example.com", + [new OrderItemDto("Widget", 10.00m, 2)] + ); + + var result = await _handler.Handle(command, CancellationToken.None); + + Assert.NotEqual(Guid.Empty, result); + await _dbContext.Received(1).SaveChangesAsync(Arg.Any()); + } +} +``` + +### Infrastructure Integration Tests (Testcontainers + Real DB) + +Test EF Core configurations against a real database in a container. + +```csharp +// tests/MyApp.Infrastructure.IntegrationTests/Persistence/ApplicationDbContextTests.cs +using Microsoft.EntityFrameworkCore; +using MyApp.Domain.Entities; +using MyApp.Infrastructure.Persistence; +using Testcontainers.MsSql; + +namespace MyApp.Infrastructure.IntegrationTests.Persistence; + +public sealed class ApplicationDbContextTests : IAsyncLifetime +{ + private readonly MsSqlContainer _container = new MsSqlBuilder() + .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + .Build(); + + private ApplicationDbContext _dbContext = default!; + + public async Task InitializeAsync() + { + await _container.StartAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlServer(_container.GetConnectionString()) + .Options; + + _dbContext = new ApplicationDbContext(options); + await _dbContext.Database.EnsureCreatedAsync(); + } + + public async Task DisposeAsync() + { + await _dbContext.DisposeAsync(); + await _container.DisposeAsync(); + } + + [Fact] + public async Task SaveOrder_ShouldPersistWithItems() + { + var order = Order.Create("test@example.com"); + order.AddItem("Widget", 10.00m, 2); + + _dbContext.Orders.Add(order); + await _dbContext.SaveChangesAsync(); + + var loaded = await _dbContext.Orders + .Include(o => o.Items) + .FirstAsync(o => o.Id == order.Id); + + Assert.Equal("test@example.com", loaded.CustomerEmail); + Assert.Single(loaded.Items); + } +} +``` + +### WebApi Integration Tests (WebApplicationFactory) + +End-to-end HTTP tests against the full middleware pipeline. + +```csharp +// tests/MyApp.WebApi.IntegrationTests/OrderEndpointTests.cs +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using MyApp.Infrastructure.Persistence; +using Testcontainers.MsSql; + +namespace MyApp.WebApi.IntegrationTests; + +public sealed class OrderEndpointTests : IAsyncLifetime +{ + private readonly MsSqlContainer _container = new MsSqlBuilder() + .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + .Build(); + + private WebApplicationFactory _factory = default!; + private HttpClient _client = default!; + + public async Task InitializeAsync() + { + await _container.StartAsync(); + + _factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + // Replace the DB with the container instance + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + if (descriptor is not null) services.Remove(descriptor); + + services.AddDbContext(options => + options.UseSqlServer(_container.GetConnectionString())); + }); + }); + + _client = _factory.CreateClient(); + + // Ensure DB is created + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + } + + public async Task DisposeAsync() + { + _client.Dispose(); + await _factory.DisposeAsync(); + await _container.DisposeAsync(); + } + + [Fact] + public async Task CreateOrder_WithValidPayload_ShouldReturn201() + { + var payload = new + { + CustomerEmail = "test@example.com", + Items = new[] + { + new { ProductName = "Widget", Price = 10.00m, Quantity = 2 } + } + }; + + var response = await _client.PostAsJsonAsync("/api/v1/orders", payload); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + [Fact] + public async Task CreateOrder_WithInvalidEmail_ShouldReturn422() + { + var payload = new + { + CustomerEmail = "not-an-email", + Items = new[] + { + new { ProductName = "Widget", Price = 10.00m, Quantity = 2 } + } + }; + + var response = await _client.PostAsJsonAsync("/api/v1/orders", payload); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + } + + [Fact] + public async Task GetOrder_WithNonExistentId_ShouldReturn404() + { + var response = await _client.GetAsync($"/api/v1/orders/{Guid.NewGuid()}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} +``` + +## Testing Ratios for Clean Architecture + +| Layer | Test Type | Volume | Why | +|-------|-----------|--------|-----| +| Domain | Unit tests | High | Pure logic, fast, no setup cost | +| Application | Unit tests | High | Handler orchestration, validation rules | +| Infrastructure | Integration tests | Medium | EF configs, Dapper queries against real DB | +| WebApi | Integration tests | Low-Medium | HTTP pipeline, routing, serialization | + +The dependency rule means domain and application tests run in milliseconds with zero infrastructure. This is why Clean Architecture produces more unit tests than other patterns. + +## Key Design Decisions + +**No Repository Pattern**: EF Core's `DbContext` already implements Unit of Work and provides `DbSet` as a collection-like abstraction. Adding a repository layer on top introduces indirection with no benefit. The `IApplicationDbContext` interface gives you testability. Query handlers project directly from `DbSet` using LINQ. + +**FluentValidation in Pipeline + Domain Invariants**: Two levels of validation serve different purposes. FluentValidation in the MediatR pipeline validates input shape (email format, required fields, string lengths). Domain entities validate business invariants (cannot submit empty order, cannot add items to submitted order). Pipeline validation returns 422. Domain violations return 409. + +**MediatR for CQRS**: Separates read and write paths at the application layer. Commands go through validation pipeline and mutate state. Queries can bypass heavy behaviors and project DTOs directly. This separation allows independent optimization of each path. + +**Dapper for Performance-Critical Reads**: EF Core is the primary data access tool. Dapper is used selectively for reporting queries, dashboard aggregations, or any read path where EF Core's overhead is measurable. Do not default to Dapper; reach for it when profiling shows a need. + +**ProblemDetails (RFC 9457)**: A single exception middleware maps all custom exceptions to standard HTTP problem responses. Consumers get consistent, machine-readable error payloads across every endpoint. diff --git a/plugins/dotnet-backend/skills/dotnet-backend/stacks/modular-monolith.md b/plugins/dotnet-backend/skills/dotnet-backend/stacks/modular-monolith.md new file mode 100644 index 0000000..f8740bd --- /dev/null +++ b/plugins/dotnet-backend/skills/dotnet-backend/stacks/modular-monolith.md @@ -0,0 +1,1058 @@ +# Modular Monolith Stack + +## Overview + +The Modular Monolith architecture organizes a single deployable unit into independent modules with explicit boundaries, each owning its own domain, data, and API surface. Modules communicate through integration events and public contracts -- never by reaching into each other's internals. This gives you the boundary discipline of microservices without the distributed-systems tax. + +## When to Choose + +- Multiple bounded contexts with distinct domain models that should stay isolated +- Team wants service-like boundaries but is not ready for distributed infrastructure (separate databases, message brokers, container orchestration) +- System will likely evolve toward microservices -- modular monolith is the safest on-ramp +- Two to four teams working on the same deployable, needing clear ownership lines +- Complex domain where different modules have different consistency and complexity needs + +## When to Avoid + +- Simple CRUD application with a single bounded context -- use Vertical Slice instead +- Team of one or two developers on a small API -- Clean Architecture or Vertical Slice is less ceremony +- You already have distributed infrastructure and need independent deployment per service today + +## Solution Structure + +``` +MyApp/ + MyApp.sln + src/ + Bootstrapper/ # Host project -- composes all modules + Bootstrapper.csproj + Program.cs + appsettings.json + Modules/ + Catalog/ + Catalog.Api/ # Module endpoints (Minimal APIs or Controllers) + CatalogModule.cs + Endpoints/ + Catalog.Application/ # Commands, queries, handlers, validators + Commands/ + Queries/ + Validators/ + Catalog.Domain/ # Entities, value objects, domain events + Entities/ + Events/ + Catalog.Infrastructure/ # Module-specific DbContext, EF configs + Data/ + CatalogDbContext.cs + Configurations/ + Services/ + Catalog.IntegrationEvents/ # Public contracts other modules may consume + ProductPriceChangedEvent.cs + Orders/ + Orders.Api/ + OrdersModule.cs + Endpoints/ + Orders.Application/ + Commands/ + Queries/ + Validators/ + Orders.Domain/ + Entities/ + Events/ + Orders.Infrastructure/ + Data/ + OrdersDbContext.cs + Configurations/ + Services/ + Orders.IntegrationEvents/ + OrderSubmittedEvent.cs + Shared/ + Shared.Abstractions/ # IModule interface, base types, integration event bus abstraction + IModule.cs + IIntegrationEventBus.cs + IntegrationEvent.cs + Shared.Infrastructure/ # In-process event bus, shared middleware, common EF interceptors + InProcessIntegrationEventBus.cs + Middleware/ + tests/ + Modules/ + Catalog.Tests/ # Per-module integration tests + Orders.Tests/ + CrossModule.Tests/ # Contract tests verifying module boundaries + SystemIntegration.Tests/ # Full Bootstrapper integration tests +``` + +## Scaffolding Commands + +Run these commands from the solution root to create the full structure. + +```bash +# Create solution +dotnet new sln -n MyApp + +# -- Shared projects -- +dotnet new classlib -n Shared.Abstractions -o src/Shared/Shared.Abstractions +dotnet new classlib -n Shared.Infrastructure -o src/Shared/Shared.Infrastructure +dotnet sln add src/Shared/Shared.Abstractions src/Shared/Shared.Infrastructure +dotnet add src/Shared/Shared.Infrastructure reference src/Shared/Shared.Abstractions + +# -- Catalog module -- +dotnet new classlib -n Catalog.Domain -o src/Modules/Catalog/Catalog.Domain +dotnet new classlib -n Catalog.Application -o src/Modules/Catalog/Catalog.Application +dotnet new classlib -n Catalog.Infrastructure -o src/Modules/Catalog/Catalog.Infrastructure +dotnet new classlib -n Catalog.IntegrationEvents -o src/Modules/Catalog/Catalog.IntegrationEvents +dotnet new classlib -n Catalog.Api -o src/Modules/Catalog/Catalog.Api +dotnet sln add \ + src/Modules/Catalog/Catalog.Domain \ + src/Modules/Catalog/Catalog.Application \ + src/Modules/Catalog/Catalog.Infrastructure \ + src/Modules/Catalog/Catalog.IntegrationEvents \ + src/Modules/Catalog/Catalog.Api + +# Catalog project references (dependency direction: Api -> Application -> Domain, Infrastructure -> Application) +dotnet add src/Modules/Catalog/Catalog.Application reference src/Modules/Catalog/Catalog.Domain +dotnet add src/Modules/Catalog/Catalog.Infrastructure reference src/Modules/Catalog/Catalog.Application +dotnet add src/Modules/Catalog/Catalog.Infrastructure reference src/Shared/Shared.Infrastructure +dotnet add src/Modules/Catalog/Catalog.Api reference src/Modules/Catalog/Catalog.Application +dotnet add src/Modules/Catalog/Catalog.Api reference src/Modules/Catalog/Catalog.Infrastructure +dotnet add src/Modules/Catalog/Catalog.Api reference src/Shared/Shared.Abstractions +dotnet add src/Modules/Catalog/Catalog.IntegrationEvents reference src/Shared/Shared.Abstractions + +# -- Orders module (same pattern) -- +dotnet new classlib -n Orders.Domain -o src/Modules/Orders/Orders.Domain +dotnet new classlib -n Orders.Application -o src/Modules/Orders/Orders.Application +dotnet new classlib -n Orders.Infrastructure -o src/Modules/Orders/Orders.Infrastructure +dotnet new classlib -n Orders.IntegrationEvents -o src/Modules/Orders/Orders.IntegrationEvents +dotnet new classlib -n Orders.Api -o src/Modules/Orders/Orders.Api +dotnet sln add \ + src/Modules/Orders/Orders.Domain \ + src/Modules/Orders/Orders.Application \ + src/Modules/Orders/Orders.Infrastructure \ + src/Modules/Orders/Orders.IntegrationEvents \ + src/Modules/Orders/Orders.Api + +dotnet add src/Modules/Orders/Orders.Application reference src/Modules/Orders/Orders.Domain +dotnet add src/Modules/Orders/Orders.Infrastructure reference src/Modules/Orders/Orders.Application +dotnet add src/Modules/Orders/Orders.Infrastructure reference src/Shared/Shared.Infrastructure +dotnet add src/Modules/Orders/Orders.Api reference src/Modules/Orders/Orders.Application +dotnet add src/Modules/Orders/Orders.Api reference src/Modules/Orders/Orders.Infrastructure +dotnet add src/Modules/Orders/Orders.Api reference src/Shared/Shared.Abstractions + +# Cross-module reference: Orders consumes Catalog's integration events (NOT Catalog internals) +dotnet add src/Modules/Orders/Orders.Application reference src/Modules/Catalog/Catalog.IntegrationEvents +dotnet add src/Modules/Orders/Orders.IntegrationEvents reference src/Shared/Shared.Abstractions + +# -- Bootstrapper (host) -- +dotnet new webapi -n Bootstrapper -o src/Bootstrapper --use-minimal-apis +dotnet sln add src/Bootstrapper +dotnet add src/Bootstrapper reference src/Modules/Catalog/Catalog.Api +dotnet add src/Bootstrapper reference src/Modules/Orders/Orders.Api +dotnet add src/Bootstrapper reference src/Shared/Shared.Infrastructure + +# -- Test projects -- +dotnet new xunit -n Catalog.Tests -o tests/Modules/Catalog.Tests +dotnet new xunit -n Orders.Tests -o tests/Modules/Orders.Tests +dotnet new xunit -n CrossModule.Tests -o tests/CrossModule.Tests +dotnet new xunit -n SystemIntegration.Tests -o tests/SystemIntegration.Tests +dotnet sln add \ + tests/Modules/Catalog.Tests \ + tests/Modules/Orders.Tests \ + tests/CrossModule.Tests \ + tests/SystemIntegration.Tests + +# Test project references +dotnet add tests/Modules/Catalog.Tests reference src/Modules/Catalog/Catalog.Api +dotnet add tests/Modules/Catalog.Tests reference src/Bootstrapper +dotnet add tests/Modules/Orders.Tests reference src/Modules/Orders/Orders.Api +dotnet add tests/Modules/Orders.Tests reference src/Bootstrapper +dotnet add tests/CrossModule.Tests reference src/Bootstrapper +dotnet add tests/SystemIntegration.Tests reference src/Bootstrapper + +# -- NuGet packages -- +dotnet add src/Shared/Shared.Abstractions package MediatR.Contracts +dotnet add src/Shared/Shared.Infrastructure package MediatR +dotnet add src/Shared/Shared.Infrastructure package Microsoft.Extensions.DependencyInjection.Abstractions + +# Per-module packages (repeat for each module's Application and Infrastructure) +dotnet add src/Modules/Catalog/Catalog.Application package MediatR +dotnet add src/Modules/Catalog/Catalog.Application package FluentValidation.DependencyInjectionExtensions +dotnet add src/Modules/Catalog/Catalog.Infrastructure package Microsoft.EntityFrameworkCore.SqlServer +dotnet add src/Modules/Catalog/Catalog.Infrastructure package Dapper + +dotnet add src/Modules/Orders/Orders.Application package MediatR +dotnet add src/Modules/Orders/Orders.Application package FluentValidation.DependencyInjectionExtensions +dotnet add src/Modules/Orders/Orders.Infrastructure package Microsoft.EntityFrameworkCore.SqlServer +dotnet add src/Modules/Orders/Orders.Infrastructure package Dapper + +# Bootstrapper packages +dotnet add src/Bootstrapper package Serilog.AspNetCore +dotnet add src/Bootstrapper package Microsoft.EntityFrameworkCore.Design + +# Test packages +dotnet add tests/Modules/Catalog.Tests package Microsoft.AspNetCore.Mvc.Testing +dotnet add tests/Modules/Catalog.Tests package FluentAssertions +dotnet add tests/Modules/Catalog.Tests package Testcontainers.MsSql +dotnet add tests/Modules/Orders.Tests package Microsoft.AspNetCore.Mvc.Testing +dotnet add tests/Modules/Orders.Tests package FluentAssertions +dotnet add tests/Modules/Orders.Tests package Testcontainers.MsSql +dotnet add tests/CrossModule.Tests package Microsoft.AspNetCore.Mvc.Testing +dotnet add tests/CrossModule.Tests package FluentAssertions +dotnet add tests/SystemIntegration.Tests package Microsoft.AspNetCore.Mvc.Testing +dotnet add tests/SystemIntegration.Tests package FluentAssertions +dotnet add tests/SystemIntegration.Tests package Testcontainers.MsSql +``` + +## Module Registration Pattern + +### IModule Interface + +Define a standard contract every module implements. This keeps the Bootstrapper decoupled from module internals. + +```csharp +// Shared.Abstractions/IModule.cs +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Shared.Abstractions; + +public interface IModule +{ + string Name { get; } + void RegisterServices(IServiceCollection services, IConfiguration configuration); + void MapEndpoints(IEndpointRouteBuilder endpoints); +} +``` + +### Module Implementation + +Each module exposes a single class implementing `IModule`. This is the only public entry point the Bootstrapper knows about. + +```csharp +// Catalog.Api/CatalogModule.cs +using Catalog.Application.Commands; +using Catalog.Api.Endpoints; +using Catalog.Infrastructure.Data; +using FluentValidation; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Shared.Abstractions; + +namespace Catalog.Api; + +public class CatalogModule : IModule +{ + public string Name => "Catalog"; + + public void RegisterServices(IServiceCollection services, IConfiguration configuration) + { + // MediatR scoped to this module's assembly + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(typeof(CatalogModule).Assembly); + cfg.RegisterServicesFromAssembly(typeof(CreateProductCommand).Assembly); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); + }); + + services.AddValidatorsFromAssembly(typeof(CreateProductCommand).Assembly); + + // Module-specific DbContext with schema isolation + services.AddDbContext(options => + options.UseSqlServer( + configuration.GetConnectionString("Default"), + sql => sql.MigrationsHistoryTable("__EFMigrationsHistory", "catalog"))); + + // For Dapper queries, inject the module's DbContext and use: + // var connection = db.Database.GetDbConnection(); + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/v1/catalog") + .WithTags("Catalog") + .WithOpenApi(); + + group.MapGet("/products", GetProducts.Handle); + group.MapGet("/products/{id:int}", GetProductById.Handle); + group.MapPost("/products", CreateProduct.Handle); + group.MapPut("/products/{id:int}", UpdateProduct.Handle); + } +} +``` + +### Bootstrapper Program.cs + +The host discovers and composes all modules. No module-specific logic leaks into Program.cs. + +```csharp +// Bootstrapper/Program.cs +using Catalog.Api; +using Orders.Api; +using Shared.Abstractions; +using Shared.Infrastructure; + +var builder = WebApplication.CreateBuilder(args); + +// Discover all modules +var modules = DiscoverModules(); + +// Register shared infrastructure +builder.Services.AddSharedInfrastructure(builder.Configuration); + +// Register each module's services +foreach (var module in modules) +{ + module.RegisterServices(builder.Services, builder.Configuration); +} + +// Shared services +builder.Services.AddProblemDetails(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +app.UseExceptionHandler(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseAuthentication(); +app.UseAuthorization(); + +// Map each module's endpoints +foreach (var module in modules) +{ + module.MapEndpoints(app); +} + +app.Run(); + +// Module discovery -- explicit assembly list for predictable startup +static IModule[] DiscoverModules() +{ + var moduleTypes = new[] + { + typeof(Catalog.Api.CatalogModule), + typeof(Orders.Api.OrdersModule) + }; + + return moduleTypes + .Select(Activator.CreateInstance) + .OfType() + .ToArray(); +} + +// Make Program accessible for WebApplicationFactory in tests +public partial class Program; +``` + +## Module Communication + +Modules never reference each other's internal projects (Domain, Application, Infrastructure). Communication happens exclusively through integration events and public contracts. + +### Integration Event Abstractions + +```csharp +// Shared.Abstractions/IntegrationEvent.cs +using MediatR; + +namespace Shared.Abstractions; + +public abstract record IntegrationEvent +{ + public Guid EventId { get; init; } = Guid.NewGuid(); + public DateTime OccurredAt { get; init; } = DateTime.UtcNow; +} + +// Shared.Abstractions/IIntegrationEventBus.cs +namespace Shared.Abstractions; + +public interface IIntegrationEventBus +{ + Task PublishAsync(T integrationEvent, CancellationToken ct = default) where T : IntegrationEvent; +} + +// Shared.Abstractions/IIntegrationEventHandler.cs +namespace Shared.Abstractions; + +public interface IIntegrationEventHandler where TEvent : IntegrationEvent +{ + Task HandleAsync(TEvent @event, CancellationToken ct = default); +} +``` + +### In-Process Event Bus (MediatR-Backed) + +For a monolith, an in-process bus is sufficient. When you extract a module to a service, swap this for RabbitMQ, Kafka, or Azure Service Bus without changing publisher or handler code. + +```csharp +// Shared.Infrastructure/InProcessIntegrationEventBus.cs +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shared.Abstractions; + +namespace Shared.Infrastructure; + +public class InProcessIntegrationEventBus(IServiceProvider serviceProvider, ILogger logger) + : IIntegrationEventBus +{ + public async Task PublishAsync(T integrationEvent, CancellationToken ct = default) + where T : IntegrationEvent + { + var eventType = integrationEvent.GetType(); + logger.LogInformation("Publishing integration event {EventType} ({EventId})", + eventType.Name, integrationEvent.EventId); + + using var scope = serviceProvider.CreateScope(); + var handlerType = typeof(IIntegrationEventHandler<>).MakeGenericType(eventType); + var handlers = scope.ServiceProvider.GetServices(handlerType); + + var exceptions = new List(); + + foreach (var handler in handlers) + { + try + { + var method = handlerType.GetMethod(nameof(IIntegrationEventHandler.HandleAsync))!; + await (Task)method.Invoke(handler, [integrationEvent, ct])!; + } + catch (Exception ex) + { + exceptions.Add(ex); + logger.LogError(ex, "Handler {HandlerType} failed for {EventType}", + handler!.GetType().Name, eventType.Name); + } + } + + if (exceptions.Count > 0) + throw new AggregateException("Integration event handler(s) failed", exceptions); + } +} + +// Shared.Infrastructure/SharedInfrastructureRegistration.cs +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Shared.Abstractions; + +namespace Shared.Infrastructure; + +public static class SharedInfrastructureRegistration +{ + public static IServiceCollection AddSharedInfrastructure( + this IServiceCollection services, IConfiguration configuration) + { + services.AddSingleton(); + return services; + } +} +``` + +### Defining Integration Events (Module Public Contract) + +Each module's `IntegrationEvents` project contains only the public contracts other modules may consume. These are the only types that cross module boundaries. + +```csharp +// Catalog.IntegrationEvents/ProductPriceChangedEvent.cs +using Shared.Abstractions; + +namespace Catalog.IntegrationEvents; + +public record ProductPriceChangedEvent( + int ProductId, + string ProductName, + decimal OldPrice, + decimal NewPrice) : IntegrationEvent; + +// Catalog.IntegrationEvents/ProductCreatedEvent.cs +namespace Catalog.IntegrationEvents; + +public record ProductCreatedEvent( + int ProductId, + string Name, + string Sku, + decimal Price) : IntegrationEvent; +``` + +### Publishing Integration Events + +Publish after the domain operation succeeds and the DbContext has saved. + +```csharp +// Catalog.Application/Commands/UpdateProductPriceHandler.cs +using Catalog.Domain.Entities; +using Catalog.Infrastructure.Data; +using Catalog.IntegrationEvents; +using MediatR; +using Shared.Abstractions; + +namespace Catalog.Application.Commands; + +public class UpdateProductPriceHandler( + CatalogDbContext db, + IIntegrationEventBus eventBus) + : IRequestHandler +{ + public async Task Handle(UpdateProductPriceCommand request, CancellationToken ct) + { + var product = await db.Products.FindAsync([request.ProductId], ct) + ?? throw new NotFoundException(nameof(Product), request.ProductId); + + var oldPrice = product.Price; + product.UpdatePrice(request.NewPrice); // domain method with invariant checks + + await db.SaveChangesAsync(ct); + + // Publish integration event AFTER successful save + await eventBus.PublishAsync(new ProductPriceChangedEvent( + product.Id, product.Name, oldPrice, request.NewPrice), ct); + } +} +``` + +### Consuming Integration Events in Another Module + +The Orders module reacts to Catalog events without referencing Catalog internals. + +```csharp +// Orders.Application/IntegrationEventHandlers/ProductPriceChangedHandler.cs +using Catalog.IntegrationEvents; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Orders.Domain.Entities; +using Orders.Infrastructure.Data; +using Shared.Abstractions; + +namespace Orders.Application.IntegrationEventHandlers; + +// IMPORTANT: Integration event handlers should be idempotent. +// Consider tracking processed EventIds to handle redelivery. +public class ProductPriceChangedHandler( + OrdersDbContext db, + ILogger logger) + : IIntegrationEventHandler +{ + public async Task HandleAsync(ProductPriceChangedEvent @event, CancellationToken ct) + { + logger.LogInformation( + "Product {ProductId} price changed from {OldPrice} to {NewPrice}", + @event.ProductId, @event.OldPrice, @event.NewPrice); + + // Update cached product price in draft orders + var draftItems = await db.OrderItems + .Include(i => i.Order) + .Where(i => i.ProductId == @event.ProductId && i.Order.Status == OrderStatus.Draft) + .ToListAsync(ct); + + foreach (var item in draftItems) + { + item.UpdateUnitPrice(@event.NewPrice); + } + + await db.SaveChangesAsync(ct); + } +} +``` + +### Registering Integration Event Handlers + +Each module registers its own handlers during DI setup. + +```csharp +// In OrdersModule.RegisterServices +using Catalog.IntegrationEvents; +using Orders.Application.IntegrationEventHandlers; +using Shared.Abstractions; + +services.AddScoped, ProductPriceChangedHandler>(); +``` + +## Per-Module DbContext with Schema Separation + +Each module owns its own DbContext targeting a separate database schema. All modules share the same physical database but are logically isolated. + +```csharp +// Catalog.Infrastructure/Data/CatalogDbContext.cs +using Catalog.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Catalog.Infrastructure.Data; + +public class CatalogDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Products => Set(); + public DbSet Categories => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // All Catalog tables live in the "catalog" schema + modelBuilder.HasDefaultSchema("catalog"); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly); + } +} + +// Orders.Infrastructure/Data/OrdersDbContext.cs +using Microsoft.EntityFrameworkCore; +using Orders.Domain.Entities; + +namespace Orders.Infrastructure.Data; + +public class OrdersDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // All Orders tables live in the "orders" schema + modelBuilder.HasDefaultSchema("orders"); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly); + } +} +``` + +### EF Configuration Example + +```csharp +// Catalog.Infrastructure/Data/Configurations/ProductConfiguration.cs +using Catalog.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Catalog.Infrastructure.Data.Configurations; + +public class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Products"); // resolves to catalog.Products + builder.HasKey(p => p.Id); + builder.Property(p => p.Name).HasMaxLength(200).IsRequired(); + builder.Property(p => p.Sku).HasMaxLength(50).IsRequired(); + builder.HasIndex(p => p.Sku).IsUnique(); + builder.Property(p => p.Price).HasPrecision(18, 2); + } +} +``` + +### Migrations Per Module + +Each module manages its own migrations independently. + +```bash +# Catalog migrations +dotnet ef migrations add InitialCatalog \ + -p src/Modules/Catalog/Catalog.Infrastructure \ + -s src/Bootstrapper \ + --context CatalogDbContext + +# Orders migrations +dotnet ef migrations add InitialOrders \ + -p src/Modules/Orders/Orders.Infrastructure \ + -s src/Bootstrapper \ + --context OrdersDbContext + +# Apply all +dotnet ef database update -p src/Modules/Catalog/Catalog.Infrastructure -s src/Bootstrapper --context CatalogDbContext +dotnet ef database update -p src/Modules/Orders/Orders.Infrastructure -s src/Bootstrapper --context OrdersDbContext +``` + +## Module Endpoint Examples + +### Minimal API Endpoints + +```csharp +// Catalog.Api/Endpoints/CreateProduct.cs +using Catalog.Application.Commands; +using MediatR; +using Microsoft.AspNetCore.Http; + +namespace Catalog.Api.Endpoints; + +public static class CreateProduct +{ + public static async Task Handle( + CreateProductCommand command, + ISender sender, + CancellationToken ct) + { + var productId = await sender.Send(command, ct); + return Results.Created($"/api/v1/catalog/products/{productId}", new { id = productId }); + } +} + +// Catalog.Api/Endpoints/GetProducts.cs +using Catalog.Application.Queries; +using MediatR; +using Microsoft.AspNetCore.Http; + +namespace Catalog.Api.Endpoints; + +public static class GetProducts +{ + public static async Task Handle( + [AsParameters] GetProductsQuery query, + ISender sender, + CancellationToken ct) + { + var result = await sender.Send(query, ct); + return Results.Ok(result); + } +} +``` + +### Controller-Based Alternative + +Modules can mix API styles. Use Controllers when model binding complexity justifies the structure. + +```csharp +// Orders.Api/Controllers/OrdersController.cs +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Orders.Application.Commands; +using Orders.Application.Queries; + +namespace Orders.Api.Controllers; + +[ApiController] +[Route("api/v1/orders")] +public class OrdersController(ISender sender) : ControllerBase +{ + [HttpPost] + [ProducesResponseType(typeof(int), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Create(CreateOrderCommand command, CancellationToken ct) + { + var orderId = await sender.Send(command, ct); + return CreatedAtAction(nameof(GetById), new { id = orderId }, new { id = orderId }); + } + + [HttpGet("{id:int}")] + public async Task GetById(int id, CancellationToken ct) + { + var order = await sender.Send(new GetOrderQuery(id), ct); + return Ok(order); + } + + [HttpPost("{id:int}/submit")] + public async Task Submit(int id, CancellationToken ct) + { + await sender.Send(new SubmitOrderCommand(id), ct); + return NoContent(); + } +} +``` + +When using Controllers, add `services.AddControllers()` and `app.MapControllers()` in the module registration or Bootstrapper. + +## Testing Strategy + +### Per-Module Integration Tests + +Test each module in isolation using `WebApplicationFactory` with a real database (Testcontainers). This validates the full pipeline: endpoint, MediatR, handler, validation, DbContext. + +```csharp +// tests/Modules/Catalog.Tests/CatalogModuleFixture.cs +using Catalog.Infrastructure.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Shared.Abstractions; +using Testcontainers.MsSql; +using Xunit; + +namespace Catalog.Tests; + +public class CatalogModuleFixture : IAsyncLifetime +{ + private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder() + .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + .Build(); + + public string ConnectionString => _sqlContainer.GetConnectionString(); + + public async Task InitializeAsync() + { + await _sqlContainer.StartAsync(); + } + + public async Task DisposeAsync() + { + await _sqlContainer.DisposeAsync(); + } +} + +public class CatalogApiFactory(CatalogModuleFixture fixture) + : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + // Replace connection string with Testcontainers instance + services.RemoveAll>(); + services.AddDbContext(options => + options.UseSqlServer(fixture.ConnectionString, + sql => sql.MigrationsHistoryTable("__EFMigrationsHistory", "catalog"))); + }); + + builder.ConfigureTestServices(services => + { + // Replace integration event bus with a test spy + services.AddSingleton(); + }); + } +} + +// tests/Modules/Catalog.Tests/ProductEndpointTests.cs +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Xunit; + +namespace Catalog.Tests; + +public class ProductEndpointTests : IClassFixture +{ + private readonly HttpClient _client; + + public ProductEndpointTests(CatalogModuleFixture fixture) + { + var factory = new CatalogApiFactory(fixture); + _client = factory.CreateClient(); + } + + [Fact] + public async Task CreateProduct_ValidInput_ReturnsCreated() + { + var command = new + { + Name = "Widget", + Sku = "WDG-001", + Price = 29.99m, + CategoryId = 1 + }; + + var response = await _client.PostAsJsonAsync("/api/v1/catalog/products", command); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var body = await response.Content.ReadFromJsonAsync(); + body.GetProperty("id").GetInt32().Should().BeGreaterThan(0); + } + + [Fact] + public async Task CreateProduct_MissingSku_ReturnsProblemDetails() + { + var command = new { Name = "Widget", Sku = "", Price = 29.99m }; + + var response = await _client.PostAsJsonAsync("/api/v1/catalog/products", command); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var problem = await response.Content.ReadFromJsonAsync(); + problem!.Title.Should().Be("Validation Error"); + } +} +``` + +### Test Spy for Integration Events + +Capture published events in tests to verify module behavior without side effects. + +```csharp +// tests/Modules/Catalog.Tests/TestIntegrationEventBus.cs +using Shared.Abstractions; + +namespace Catalog.Tests; + +public class TestIntegrationEventBus : IIntegrationEventBus +{ + private readonly List _publishedEvents = []; + + public IReadOnlyList PublishedEvents => _publishedEvents.AsReadOnly(); + + public Task PublishAsync(T integrationEvent, CancellationToken ct = default) + where T : IntegrationEvent + { + _publishedEvents.Add(integrationEvent); + return Task.CompletedTask; + } + + public T? GetPublished() where T : IntegrationEvent + => _publishedEvents.OfType().FirstOrDefault(); + + public void Clear() => _publishedEvents.Clear(); +} +``` + +### Cross-Module Contract Tests + +Verify that module boundaries hold: module A publishes events that module B can deserialize and handle without runtime errors. + +```csharp +// tests/CrossModule.Tests/CatalogOrdersContractTests.cs +using System.Text.Json; +using Catalog.IntegrationEvents; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Shared.Abstractions; +using Xunit; + +namespace CrossModule.Tests; + +public class CatalogOrdersContractTests +{ + [Fact] + public void ProductPriceChangedEvent_CanBeDeserialized_ByOrdersHandler() + { + // Catalog publishes this event + var @event = new ProductPriceChangedEvent( + ProductId: 1, + ProductName: "Widget", + OldPrice: 19.99m, + NewPrice: 24.99m); + + // Serialize as if crossing a boundary + var json = JsonSerializer.Serialize(@event); + var deserialized = JsonSerializer.Deserialize(json); + + // Orders module can consume it + deserialized.Should().NotBeNull(); + deserialized!.ProductId.Should().Be(1); + deserialized.NewPrice.Should().Be(24.99m); + deserialized.EventId.Should().NotBeEmpty(); + deserialized.OccurredAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task OrdersModule_HandlesProductPriceChanged_WithoutErrors() + { + // Arrange -- set up Orders module with test database + await using var factory = new WebApplicationFactory(); + using var scope = factory.Services.CreateScope(); + var handler = scope.ServiceProvider.GetRequiredService>(); + + var @event = new ProductPriceChangedEvent(1, "Widget", 19.99m, 24.99m); + + // Act and Assert -- handler does not throw + await handler.Invoking(h => h.HandleAsync(@event)) + .Should().NotThrowAsync(); + } +} +``` + +### Full System Integration Tests + +Test multi-module workflows end-to-end through the Bootstrapper. + +```csharp +// tests/SystemIntegration.Tests/OrderWorkflowTests.cs +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace SystemIntegration.Tests; + +public class OrderWorkflowTests(WebApplicationFactory factory) + : IClassFixture> +{ + [Fact] + public async Task FullOrderWorkflow_CreateProduct_ThenOrder_ThenSubmit() + { + var client = factory.CreateClient(); + + // Step 1: Create a product in Catalog module + var productResponse = await client.PostAsJsonAsync("/api/v1/catalog/products", new + { + Name = "Test Product", + Sku = "TST-001", + Price = 49.99m + }); + productResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + // Step 2: Create an order in Orders module + var orderResponse = await client.PostAsJsonAsync("/api/v1/orders", new + { + CustomerId = "cust-1", + Items = new[] { new { ProductId = 1, Quantity = 2 } } + }); + orderResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + // Step 3: Submit the order + var submitResponse = await client.PostAsync("/api/v1/orders/1/submit", null); + submitResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + + // Step 4: Verify order status + var getResponse = await client.GetFromJsonAsync("/api/v1/orders/1"); + getResponse.GetProperty("status").GetString().Should().Be("Submitted"); + } +} +``` + +## Boundary Enforcement Rules + +These rules prevent modules from degrading into a distributed monolith or a tightly coupled mess. + +1. **No cross-module DbContext access.** Module A never injects or queries Module B's DbContext. Data flows through integration events or public API calls. + +2. **IntegrationEvents project is the public contract.** Only reference another module's `IntegrationEvents` project, never its `Application`, `Domain`, or `Infrastructure`. + +3. **No shared entity types.** If both modules need a `Product` concept, each defines its own representation. The Catalog module owns the source of truth; Orders keeps a local projection updated via events. + +4. **Integration events are immutable records.** Once published, their shape is a contract. Use additive changes (new optional properties) for evolution; never remove or rename fields. + +5. **Each module has its own MediatR registration.** Handlers from Module A should not accidentally process commands from Module B. + +## Migration Path to Microservices + +The modular monolith is designed so each module can be extracted into an independent service with minimal code changes. + +### What Changes When Extracting a Module + +| Concern | Monolith (Current) | Microservice (Future) | +|---------|--------------------|-----------------------| +| **Deployment** | Single process | Separate container/process per service | +| **Database** | Shared database, separate schemas | Separate databases | +| **Event bus** | `InProcessIntegrationEventBus` | RabbitMQ, Kafka, or Azure Service Bus adapter | +| **Service calls** | In-process method calls via MediatR | HTTP/gRPC client calls | +| **Configuration** | Single `appsettings.json` | Per-service config, service discovery | +| **Auth** | Shared middleware | Per-service JWT validation or API gateway | + +### Extraction Steps + +1. **Provision a separate database** for the module. Run its EF migrations against the new database. +2. **Swap the event bus implementation** from `InProcessIntegrationEventBus` to a message broker adapter (same `IIntegrationEventBus` interface). +3. **Create a standalone ASP.NET host** for the module. Copy the module's `RegisterServices` and `MapEndpoints` into the new `Program.cs`. +4. **Replace in-process module API calls** with HTTP/gRPC clients using the existing contract types from the `IntegrationEvents` project. +5. **Update the Bootstrapper** to remove the extracted module and route its traffic to the new service (via API gateway or reverse proxy). +6. **Deploy and verify.** Run cross-module contract tests against the new service boundary. + +### Why This Works + +The key investment is the `IntegrationEvents` project and the `IIntegrationEventBus` abstraction. Because modules already communicate through serializable events and never share internal state, extraction is a deployment change rather than an architectural rewrite. The contract tests you wrote during monolith development continue to validate the boundary after extraction. + +## Key NuGet Packages Summary + +| Package | Where | Purpose | +|---------|-------|---------| +| `MediatR` | Per-module Application | CQRS, pipeline behaviors | +| `MediatR.Contracts` | Shared.Abstractions | `IRequest`, `INotification` interfaces only | +| `FluentValidation.DependencyInjectionExtensions` | Per-module Application | Input validation | +| `Microsoft.EntityFrameworkCore.SqlServer` | Per-module Infrastructure | Data access | +| `Dapper` | Per-module Infrastructure | Performance-critical queries | +| `Serilog.AspNetCore` | Bootstrapper | Structured logging | +| `Asp.Versioning.Http` | Bootstrapper or per-module Api | API versioning | +| `Microsoft.AspNetCore.Mvc.Testing` | Test projects | `WebApplicationFactory` | +| `Testcontainers.MsSql` | Test projects | Real database in tests | +| `FluentAssertions` | Test projects | Readable test assertions | diff --git a/plugins/dotnet-backend/skills/dotnet-backend/stacks/vertical-slice.md b/plugins/dotnet-backend/skills/dotnet-backend/stacks/vertical-slice.md new file mode 100644 index 0000000..aa2567c --- /dev/null +++ b/plugins/dotnet-backend/skills/dotnet-backend/stacks/vertical-slice.md @@ -0,0 +1,1041 @@ +# Vertical Slice Architecture Stack + +## Overview + +Vertical Slice Architecture organizes code by feature instead of technical layer. Each slice owns its endpoint, request/response models, validation, handler logic, and tests in a single cohesive unit. This eliminates cross-layer abstractions like repositories and services, keeping each feature self-contained and independently deployable. + +## When to Use + +- Rapid feature delivery where each slice ships independently +- Small-to-medium teams (2-8 developers) working on distinct features +- CRUD-heavy APIs with clear per-feature boundaries +- Projects that benefit from low coupling between features +- Greenfield services or microservices with focused domains + +## When to Avoid + +- Complex cross-feature domain logic requiring rich domain models and shared aggregates +- Systems where business rules span many features and need a unified domain layer +- Very large teams that need strict architectural layering for governance + +## Solution Structure + +``` +src/ + MyApp/ + Features/ + Products/ + CreateProduct.cs # Endpoint + Command + Handler + Validator + GetProduct.cs # Endpoint + Query + Handler + ListProducts.cs # Endpoint + Query + Handler + UpdateProduct.cs + DeleteProduct.cs + ProductDto.cs # Shared DTOs within the feature + Orders/ + CreateOrder.cs + GetOrder.cs + ListOrders.cs + OrderDto.cs + Common/ + Behaviors/ + ValidationBehavior.cs # MediatR pipeline behavior + LoggingBehavior.cs + Middleware/ + ExceptionHandlingMiddleware.cs + Exceptions/ + AppException.cs + NotFoundException.cs + ValidationException.cs + ConflictException.cs + Extensions/ + ServiceCollectionExtensions.cs + WebApplicationExtensions.cs + Data/ + AppDbContext.cs + Configurations/ + ProductConfiguration.cs + OrderConfiguration.cs + Domain/ + Product.cs # Entity with domain invariants + Order.cs + OrderItem.cs + Program.cs + appsettings.json +tests/ + MyApp.Tests/ + Features/ + Products/ + CreateProductTests.cs + GetProductTests.cs + ListProductsTests.cs + Common/ + TestWebApplicationFactory.cs + TestDatabaseFixture.cs +``` + +## Scaffolding Commands + +```bash +# Install EF Core tools (required for migrations) +dotnet tool install --global dotnet-ef + +# Create solution +dotnet new sln -n MyApp + +# Create web API project (Minimal API template) +dotnet new webapi -n MyApp -o src/MyApp --use-minimal-apis + +# Create test project +dotnet new xunit -n MyApp.Tests -o tests/MyApp.Tests + +# Add projects to solution +dotnet sln add src/MyApp/MyApp.csproj +dotnet sln add tests/MyApp.Tests/MyApp.Tests.csproj + +# Add test project reference +dotnet add tests/MyApp.Tests reference src/MyApp + +# Create feature directories +mkdir -p src/MyApp/Features/Products +mkdir -p src/MyApp/Features/Orders +mkdir -p src/MyApp/Common/Behaviors +mkdir -p src/MyApp/Common/Middleware +mkdir -p src/MyApp/Common/Exceptions +mkdir -p src/MyApp/Common/Extensions +mkdir -p src/MyApp/Data/Configurations +mkdir -p src/MyApp/Domain +mkdir -p tests/MyApp.Tests/Features/Products +mkdir -p tests/MyApp.Tests/Common +``` + +## Key Package Installations + +### Application Project + +```bash +cd src/MyApp + +# MediatR for CQRS +dotnet add package MediatR + +# EF Core (primary data access) +dotnet add package Microsoft.EntityFrameworkCore +dotnet add package Microsoft.EntityFrameworkCore.Design +dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL # or your provider + +# Dapper for performance-critical queries +dotnet add package Dapper + +# Validation +dotnet add package FluentValidation +dotnet add package FluentValidation.DependencyInjectionExtensions + +# Carter (optional - endpoint organization alternative) +dotnet add package Carter +``` + +### Test Project + +```bash +cd tests/MyApp.Tests + +dotnet add package Microsoft.AspNetCore.Mvc.Testing +dotnet add package Testcontainers.PostgreSql # real database in tests +dotnet add package Respawn # test data isolation +dotnet add package FluentAssertions +dotnet add package NSubstitute # mocking when needed +dotnet add package Bogus # test data generation +``` + +## Code Examples + +### Domain Entity with Invariants + +```csharp +// src/MyApp/Domain/Product.cs +namespace MyApp.Domain; + +public class Product +{ + public Guid Id { get; private set; } + public string Name { get; private set; } = string.Empty; + public string Sku { get; private set; } = string.Empty; + public decimal Price { get; private set; } + public int StockQuantity { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime? UpdatedAt { get; private set; } + + private Product() { } // EF Core constructor + + public static Product Create(string name, string sku, decimal price, int stockQuantity) + { + if (price <= 0) + throw new ArgumentException("Price must be greater than zero.", nameof(price)); + + if (stockQuantity < 0) + throw new ArgumentException("Stock quantity cannot be negative.", nameof(stockQuantity)); + + return new Product + { + Id = Guid.NewGuid(), + Name = name, + Sku = sku, + Price = price, + StockQuantity = stockQuantity, + CreatedAt = DateTime.UtcNow + }; + } + + public void AdjustStock(int quantity) + { + if (StockQuantity + quantity < 0) + throw new InvalidOperationException("Insufficient stock."); + + StockQuantity += quantity; + UpdatedAt = DateTime.UtcNow; + } +} +``` + +### Complete Vertical Slice: CreateProduct + +```csharp +// src/MyApp/Features/Products/CreateProduct.cs +using FluentValidation; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using MyApp.Data; +using MyApp.Domain; + +namespace MyApp.Features.Products; + +// --- Endpoint --- +public static class CreateProductEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapPost("/api/products", async ( + CreateProductCommand command, + ISender sender, + CancellationToken ct) => + { + var result = await sender.Send(command, ct); + return Results.Created($"/api/products/{result.Id}", result); + }) + .WithName("CreateProduct") + .WithTags("Products") + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .WithOpenApi(); + } +} + +// --- Command --- +public record CreateProductCommand( + string Name, + string Sku, + decimal Price, + int StockQuantity) : IRequest; + +// --- Response --- +public record CreateProductResponse(Guid Id, string Name, string Sku, decimal Price); + +// --- Validator --- +public class CreateProductValidator : AbstractValidator +{ + public CreateProductValidator(AppDbContext db) + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(200); + + RuleFor(x => x.Sku) + .NotEmpty() + .MaximumLength(50) + .MustAsync(async (sku, ct) => + !await db.Products.AnyAsync(p => p.Sku == sku, ct)) + .WithMessage("SKU already exists."); + + RuleFor(x => x.Price) + .GreaterThan(0); + + RuleFor(x => x.StockQuantity) + .GreaterThanOrEqualTo(0); + } +} + +// --- Handler --- +public class CreateProductHandler : IRequestHandler +{ + private readonly AppDbContext _db; + + public CreateProductHandler(AppDbContext db) + { + _db = db; + } + + public async Task Handle( + CreateProductCommand request, + CancellationToken cancellationToken) + { + var product = Product.Create( + request.Name, + request.Sku, + request.Price, + request.StockQuantity); + + _db.Products.Add(product); + await _db.SaveChangesAsync(cancellationToken); + + return new CreateProductResponse(product.Id, product.Name, product.Sku, product.Price); + } +} +``` + +### Query Slice with Dapper (Performance-Critical) + +```csharp +// src/MyApp/Features/Products/ListProducts.cs +using Dapper; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using MyApp.Data; + +namespace MyApp.Features.Products; + +// --- Endpoint --- +public static class ListProductsEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapGet("/api/products", async ( + [AsParameters] ListProductsQuery query, + ISender sender, + CancellationToken ct) => + { + var result = await sender.Send(query, ct); + return Results.Ok(result); + }) + .WithName("ListProducts") + .WithTags("Products") + .Produces>() + .WithOpenApi(); + } +} + +// --- Query --- +public record ListProductsQuery( + string? Search, + int Page = 1, + int PageSize = 20) : IRequest>; + +// --- Response --- +public record ProductListItem(Guid Id, string Name, string Sku, decimal Price, int StockQuantity); +public record PagedResult(IReadOnlyList Items, int TotalCount, int Page, int PageSize); + +// --- Handler (Dapper for read performance) --- +public sealed class ListProductsHandler(AppDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + ListProductsQuery request, + CancellationToken cancellationToken) + { + var connection = db.Database.GetDbConnection(); + var offset = (request.Page - 1) * request.PageSize; + + const string countSql = """ + SELECT COUNT(*) + FROM "Products" + WHERE (@Search IS NULL OR "Name" ILIKE '%' || @Search || '%') + """; + + const string dataSql = """ + SELECT "Id", "Name", "Sku", "Price", "StockQuantity" + FROM "Products" + WHERE (@Search IS NULL OR "Name" ILIKE '%' || @Search || '%') + ORDER BY "CreatedAt" DESC + LIMIT @PageSize OFFSET @Offset + """; + + var parameters = new { request.Search, request.PageSize, Offset = offset }; + + var totalCount = await connection.ExecuteScalarAsync(countSql, parameters); + var items = (await connection.QueryAsync(dataSql, parameters)).ToList(); + + return new PagedResult(items, totalCount, request.Page, request.PageSize); + } +} +``` + +### Custom Exceptions and ProblemDetails Middleware + +```csharp +// src/MyApp/Common/Exceptions/AppException.cs +namespace MyApp.Common.Exceptions; + +public abstract class AppException(string message) : Exception(message); +``` + +```csharp +// src/MyApp/Common/Exceptions/NotFoundException.cs +namespace MyApp.Common.Exceptions; + +public sealed class NotFoundException : AppException +{ + public string EntityName { get; } + public object Key { get; } + + public NotFoundException(string entityName, object key) + : base($"{entityName} with key '{key}' was not found.") + { + EntityName = entityName; + Key = key; + } +} + +// src/MyApp/Common/Exceptions/ConflictException.cs +namespace MyApp.Common.Exceptions; + +public sealed class ConflictException(string message) : AppException(message); +``` + +```csharp +// src/MyApp/Common/Middleware/ExceptionHandlingMiddleware.cs +using System.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using MyApp.Common.Exceptions; +using ValidationException = MyApp.Common.Exceptions.ValidationException; + +namespace MyApp.Common.Middleware; + +public class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + await HandleExceptionAsync(context, ex); + } + } + + private async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + var problemDetails = exception switch + { + NotFoundException notFound => new ProblemDetails + { + Status = StatusCodes.Status404NotFound, + Title = "Resource Not Found", + Detail = notFound.Message, + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.5" + }, + ValidationException validation => new ProblemDetails + { + Status = StatusCodes.Status422UnprocessableEntity, + Title = "Validation Failed", + Detail = "One or more validation errors occurred.", + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.21", + Extensions = { ["errors"] = validation.Errors } + }, + ConflictException conflict => new ProblemDetails + { + Status = StatusCodes.Status409Conflict, + Title = "Conflict", + Detail = conflict.Message, + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10" + }, + _ => new ProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Title = "Internal Server Error", + Detail = "An unexpected error occurred.", + Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1" + } + }; + + problemDetails.Extensions["traceId"] = Activity.Current?.Id ?? context.TraceIdentifier; + + _logger.LogError(exception, "Unhandled exception: {Message}", exception.Message); + + context.Response.StatusCode = problemDetails.Status ?? 500; + await context.Response.WriteAsJsonAsync(problemDetails); + } +} +``` + +### MediatR Pipeline: Validation Behavior + +```csharp +// src/MyApp/Common/Behaviors/ValidationBehavior.cs +using FluentValidation; +using MediatR; +using ValidationException = MyApp.Common.Exceptions.ValidationException; + +namespace MyApp.Common.Behaviors; + +public class ValidationBehavior : IPipelineBehavior + where TRequest : notnull +{ + private readonly IEnumerable> _validators; + + public ValidationBehavior(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + if (!_validators.Any()) + return await next(); + + var context = new ValidationContext(request); + + var results = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = results + .SelectMany(r => r.Errors) + .Where(f => f is not null) + .ToList(); + + if (failures.Count != 0) + { + var errors = failures + .GroupBy(f => f.PropertyName) + .ToDictionary( + g => g.Key, + g => g.Select(f => f.ErrorMessage).ToArray()); + + throw new ValidationException(errors); + } + + return await next(); + } +} +``` + +```csharp +// src/MyApp/Common/Exceptions/ValidationException.cs +namespace MyApp.Common.Exceptions; + +public sealed class ValidationException : AppException +{ + public IDictionary Errors { get; } + + public ValidationException(IDictionary errors) + : base("One or more validation errors occurred.") + { + Errors = errors; + } +} +``` + +### MediatR Pipeline: Logging Behavior + +```csharp +// src/MyApp/Common/Behaviors/LoggingBehavior.cs +using System.Diagnostics; +using MediatR; + +namespace MyApp.Common.Behaviors; + +public class LoggingBehavior : IPipelineBehavior + where TRequest : notnull +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + _logger.LogInformation("Handling {RequestName}", requestName); + + var stopwatch = Stopwatch.StartNew(); + var response = await next(); + stopwatch.Stop(); + + _logger.LogInformation("Handled {RequestName} in {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + return response; + } +} +``` + +### Extension Methods for DI Registration + +```csharp +// src/MyApp/Common/Extensions/ServiceCollectionExtensions.cs +using FluentValidation; +using MediatR; +using Microsoft.EntityFrameworkCore; +using MyApp.Common.Behaviors; +using MyApp.Data; +using Npgsql; + +namespace MyApp.Common.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); + }); + + services.AddValidatorsFromAssemblyContaining(); + + return services; + } + + public static IServiceCollection AddPersistence( + this IServiceCollection services, + IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("Default") + ?? throw new InvalidOperationException("Connection string 'Default' not found."); + + // EF Core for commands (PostgreSQL) + // For SQL Server alternative: options.UseSqlServer(connectionString) + services.AddDbContext(options => + options.UseNpgsql(connectionString)); + + // For Dapper queries, inject AppDbContext and use its connection: + // var connection = db.Database.GetDbConnection(); + // This ensures proper connection lifecycle management. + // Alternatively, use NpgsqlDataSource (recommended for .NET 9): + services.AddNpgsqlDataSource(connectionString); + + return services; + } +} +``` + +### Feature Endpoint Registration + +```csharp +// src/MyApp/Common/Extensions/WebApplicationExtensions.cs +using MyApp.Features.Products; +using MyApp.Features.Orders; + +namespace MyApp.Common.Extensions; + +public static class WebApplicationExtensions +{ + public static WebApplication MapFeatureEndpoints(this WebApplication app) + { + CreateProductEndpoint.Map(app); + GetProductEndpoint.Map(app); + ListProductsEndpoint.Map(app); + UpdateProductEndpoint.Map(app); + DeleteProductEndpoint.Map(app); + + CreateOrderEndpoint.Map(app); + GetOrderEndpoint.Map(app); + ListOrdersEndpoint.Map(app); + + return app; + } +} +``` + +### Program.cs + +```csharp +// src/MyApp/Program.cs +using MyApp.Common.Extensions; +using MyApp.Common.Middleware; + +var builder = WebApplication.CreateBuilder(args); + +// Service registration via extension methods +builder.Services.AddApplicationServices(); +builder.Services.AddPersistence(builder.Configuration); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddProblemDetails(); + +var app = builder.Build(); + +// Middleware pipeline +app.UseMiddleware(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +// Map all feature endpoints +app.MapFeatureEndpoints(); + +app.Run(); + +// Make Program accessible to WebApplicationFactory in tests +public partial class Program { } +``` + +### Carter Alternative for Endpoint Organization + +Carter provides a module-based approach to grouping Minimal API endpoints per feature. + +```csharp +// src/MyApp/Features/Products/ProductModule.cs +using Carter; +using MediatR; + +namespace MyApp.Features.Products; + +public class ProductModule : ICarterModule +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/products").WithTags("Products"); + + group.MapPost("/", async (CreateProductCommand command, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(command, ct); + return Results.Created($"/api/products/{result.Id}", result); + }) + .WithName("CreateProduct") + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem(); + + group.MapGet("/", async ([AsParameters] ListProductsQuery query, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(query, ct); + return Results.Ok(result); + }) + .WithName("ListProducts") + .Produces>(); + + group.MapGet("/{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new GetProductQuery(id), ct); + return Results.Ok(result); + }) + .WithName("GetProduct") + .Produces() + .ProducesProblem(StatusCodes.Status404NotFound); + } +} +``` + +When using Carter, register it in Program.cs instead of manual endpoint mapping: + +```csharp +// In Program.cs +builder.Services.AddCarter(); +// ... +app.MapCarter(); // replaces app.MapFeatureEndpoints() +``` + +### AppDbContext + +```csharp +// src/MyApp/Data/AppDbContext.cs +using Microsoft.EntityFrameworkCore; +using MyApp.Domain; + +namespace MyApp.Data; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet Products => Set(); + public DbSet Orders => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + } +} +``` + +## Testing Strategy + +Vertical Slice Architecture favors integration tests that exercise the full pipeline (HTTP request through MediatR to database and back). Unit tests are reserved for complex domain logic. + +### Test Infrastructure + +```csharp +// tests/MyApp.Tests/Common/TestWebApplicationFactory.cs +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using MyApp.Data; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace MyApp.Tests.Common; + +public class TestWebApplicationFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .Build(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + // Remove the existing DbContext registration + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + + if (descriptor is not null) + services.Remove(descriptor); + + // Register test database backed by Testcontainers + services.AddDbContext(options => + options.UseNpgsql(_dbContainer.GetConnectionString())); + + // Also replace Dapper data source for test database + var dataSourceDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(NpgsqlDataSource)); + if (dataSourceDescriptor is not null) + services.Remove(dataSourceDescriptor); + + services.AddNpgsqlDataSource(_dbContainer.GetConnectionString()); + }); + } + + public async Task InitializeAsync() + { + await _dbContainer.StartAsync(); + + // Apply migrations + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + } + + public new async Task DisposeAsync() + { + await _dbContainer.DisposeAsync(); + } +} +``` + +```csharp +// tests/MyApp.Tests/Common/TestDatabaseFixture.cs +using Microsoft.EntityFrameworkCore; +using MyApp.Data; +using Respawn; + +namespace MyApp.Tests.Common; + +public class TestDatabaseFixture : IAsyncLifetime +{ + private readonly TestWebApplicationFactory _factory; + private Respawner _respawner = default!; + + public HttpClient Client { get; private set; } = default!; + + public TestDatabaseFixture(TestWebApplicationFactory factory) + { + _factory = factory; + } + + public async Task InitializeAsync() + { + Client = _factory.CreateClient(); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var connection = db.Database.GetDbConnection(); + await connection.OpenAsync(); + + _respawner = await Respawner.CreateAsync(connection, new RespawnerOptions + { + DbAdapter = DbAdapter.Postgres, + SchemasToInclude = ["public"] + }); + } + + public async Task ResetDatabaseAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var connection = db.Database.GetDbConnection(); + await connection.OpenAsync(); + await _respawner.ResetAsync(connection); + } + + public Task DisposeAsync() => Task.CompletedTask; +} +``` + +### Integration Tests: Full Slice Through HTTP + +```csharp +// tests/MyApp.Tests/Features/Products/CreateProductTests.cs +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using MyApp.Features.Products; +using MyApp.Tests.Common; + +namespace MyApp.Tests.Features.Products; + +public class CreateProductTests : IClassFixture, IAsyncLifetime +{ + private readonly TestDatabaseFixture _fixture; + + public CreateProductTests(TestWebApplicationFactory factory) + { + _fixture = new TestDatabaseFixture(factory); + } + + public Task InitializeAsync() => _fixture.InitializeAsync(); + public Task DisposeAsync() => _fixture.ResetDatabaseAsync(); + + [Fact] + public async Task CreateProduct_WithValidData_Returns201AndProduct() + { + // Arrange + var command = new CreateProductCommand( + Name: "Widget", + Sku: "WDG-001", + Price: 29.99m, + StockQuantity: 100); + + // Act + var response = await _fixture.Client.PostAsJsonAsync("/api/products", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var product = await response.Content.ReadFromJsonAsync(); + product.Should().NotBeNull(); + product!.Name.Should().Be("Widget"); + product.Sku.Should().Be("WDG-001"); + product.Price.Should().Be(29.99m); + + response.Headers.Location!.ToString().Should().Contain(product.Id.ToString()); + } + + [Fact] + public async Task CreateProduct_WithDuplicateSku_Returns422() + { + // Arrange - create first product + var first = new CreateProductCommand("Widget A", "WDG-DUP", 10m, 5); + await _fixture.Client.PostAsJsonAsync("/api/products", first); + + // Act - attempt duplicate SKU + var duplicate = new CreateProductCommand("Widget B", "WDG-DUP", 20m, 10); + var response = await _fixture.Client.PostAsJsonAsync("/api/products", duplicate); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + } + + [Theory] + [InlineData("", "SKU-1", 10, 0)] // empty name + [InlineData("Widget", "", 10, 0)] // empty SKU + [InlineData("Widget", "SKU-1", -1, 0)] // negative price + [InlineData("Widget", "SKU-1", 10, -5)] // negative stock + public async Task CreateProduct_WithInvalidData_Returns422( + string name, string sku, decimal price, int stock) + { + var command = new CreateProductCommand(name, sku, price, stock); + + var response = await _fixture.Client.PostAsJsonAsync("/api/products", command); + + response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + } +} +``` + +### Unit Test for Domain Invariants + +```csharp +// tests/MyApp.Tests/Domain/ProductTests.cs +using FluentAssertions; +using MyApp.Domain; + +namespace MyApp.Tests.Domain; + +public class ProductTests +{ + [Fact] + public void Create_WithNegativePrice_ThrowsArgumentException() + { + var act = () => Product.Create("Widget", "SKU-1", -10m, 5); + + act.Should().Throw() + .WithParameterName("price"); + } + + [Fact] + public void AdjustStock_BelowZero_ThrowsInvalidOperationException() + { + var product = Product.Create("Widget", "SKU-1", 10m, 3); + + var act = () => product.AdjustStock(-5); + + act.Should().Throw() + .WithMessage("*Insufficient stock*"); + } +} +``` + +## Architecture Decision Summary + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Project organization | Feature folders | Each slice is self-contained, reduces merge conflicts | +| CQRS mediator | MediatR | Decouples endpoint from handler, enables pipeline behaviors | +| Data access (writes) | EF Core directly in handler | No repository abstraction; handler owns its data access | +| Data access (reads) | Dapper for perf-critical | Raw SQL performance without ORM overhead for list queries | +| Validation | FluentValidation + MediatR pipeline | Automatic validation before handler executes | +| Error handling | ProblemDetails (RFC 9457) | Standardized error responses with structured detail | +| Endpoint grouping | Static Map methods or Carter modules | Feature-local endpoint registration | +| Testing emphasis | Integration tests via WebApplicationFactory | Tests the real pipeline end-to-end, catches wiring issues | +| Test database | Testcontainers (PostgreSQL) | Real database behavior, no in-memory fakes | +| Test isolation | Respawn | Fast database reset between tests without recreation | + +## Next Steps + +1. Define domain entities and their invariants +2. Create the `AppDbContext` with EF Core configurations +3. Generate initial migration with `dotnet ef migrations add InitialCreate` +4. Build the first vertical slice (endpoint + command + handler + validator) +5. Wire up MediatR pipeline behaviors (validation, logging) +6. Add exception handling middleware with ProblemDetails +7. Write integration tests for the first slice using WebApplicationFactory +8. Repeat for each feature -- each slice is independent