Architecture v2 — ASP.NET Core Feature-Based
1. Layers
| Layer | Purpose | Depends on |
|---|---|---|
| Endpoints / Controllers | HTTP binding, auth, execute | Features, Shared |
| Features | All business logic | Features (barrel only), Shared |
| Shared | Abstractions, infrastructure | Shared only |
Foundation features depend only on Shared. Domain features depend on Shared + foundation features. Domain → domain forbidden.
2. Project Structure
src/
├── Program.cs
│
├── Features/
│ └── <Name>/
│ ├── CreateX.cs # command record + handler
│ ├── UpdateX.cs # command record + handler
│ ├── GetX.cs # query record + handler
│ ├── Contracts.cs # DTOs, enums
│ ├── Endpoints.cs # MapXEndpoints(WebApplication) — preferred
│ └── Controller.cs # fallback when Minimal API is not viable
│
├── Shared/
│ ├── Abstractions/ # ICommand, IQuery, IValidator<T>
│ ├── Persistence/ # DbContext, migrations
│ ├── Telemetry/ # OpenTelemetry wiring
│ ├── Resilience/ # Polly pipelines, standard handlers
│ └── GlobalUsings.cs
3. Feature Types
| Type | Purpose | Depends on | Example |
|---|---|---|---|
| Foundation | Data models, core CRUD | Shared only | Users, Orders, Products |
| Domain | Business processes | Shared + foundation | Checkout, Onboarding, Dashboard |
Extract to foundation when 2+ domain features need the same logic.
4. Commands & Queries
| Returns | Mutates | Failure | |
|---|---|---|---|
| Command | Task |
✅ | Throws domain exception |
| Query | Task<T?> |
❌ | Returns null for not-found; throws for unexpected errors |
One HTTP request = one command or one query. Aggregation queries allowed (multiple reads, single response). Never aggregate commands.
Command record and handler in one file. Query record and handler in one file.
Commands throw on failure. Queries return nullable — null means “not found.” All exceptions flow to the global error middleware. One error model. One conversion point.
5. Endpoints & Controllers
Transport-only. Bind HTTP → command/query → execute handler directly.
Prefer Minimal API (Endpoints.cs). Fall back to a controller when Minimal API is not viable (e.g., complex model binding, MVC filters, or legacy constraints). Both follow the same rules.
Must: bind HTTP, enforce auth/authz, call handler directly, map outcome → HTTP response.
Must NOT: business logic, call repositories, compose multiple commands.
Mapping rules at the endpoint:
- Command throws → middleware converts to ProblemDetails
- Query returns
null→ 404 - Query throws → middleware converts to ProblemDetails
- Everything else → 200 with body
Feature endpoints via extension method groups: app.MapOrderEndpoints(). Controllers via [ApiController] + constructor-injected handlers.
6. Cross-Cutting Concerns
Handlers own their cross-cutting concerns explicitly. No hidden pipeline. Every handler must:
- Validate — call FluentValidation validator, throw
ValidationExceptionon failure - Trace — create an OpenTelemetry span named
{Feature}.{Operation}with meaningful attributes - Log — log request start, success/failure, and duration
- Meter — increment success/failure counters and record duration histogram
Consistency is enforced by the spec, not by runtime infrastructure. Agents regenerate all handlers when cross-cutting rules change.
Order: Validate → Trace → Log/Meter (start) → Business logic → Log/Meter (end).
7. Validation
FluentValidation. AbstractValidator<TRequest>. Stateless. Return ValidationResult, never throw.
Each handler calls its validator explicitly as the first step. After validation passes, business logic assumes valid input — no re-validation scattered throughout the handler body.
8. Error Handling
One error model. One translation point.
Domain Exceptions
Base class DomainException for all expected business failures. Carries an ErrorCode string for API consumers. Named subtypes for common cases.
DomainException (base, maps to 422)
├── ValidationException → 400
├── UnauthorizedException → 403
├── NotFoundException → 404
├── ConflictException → 409
└── CustomDomainException → 422 (generic)
Middleware
Global exception middleware catches everything and converts to RFC 7807 Problem Details.
DomainException→ 4xx (client error, expected)- Everything else → 500 (server error, unexpected)
Middleware extracts ErrorCode from DomainException into ProblemDetails detail field. No raw stack traces to clients.
Handlers throw domain exceptions freely. Endpoints never catch — let them bubble to the middleware.
Query handlers return null for not-found (no throw). Command handlers throw for failures.
9. Security
Fallback policy — authenticated by default. Opt out explicitly.
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
[AllowAnonymous] only where justified and reviewed.
- JWT Bearer tokens
- Role-based authorization
- CORS restricted to production domains
- Rate limiting (built-in
AddRateLimiter/UseRateLimiter) - Security headers via
NetEscapades.AspNetCore.SecurityHeaders
Middleware order: CORS → Auth → Authorization → Security Headers → Rate Limiting → Endpoint → Handler.
10. Resilience
Infrastructure-level. Microsoft.Extensions.Resilience (Polly v8). Standard resilience handlers on all HttpClient, storage, and external adapters.
Apply globally to all HTTP clients by default:
builder.Services.ConfigureHttpClientDefaults(builder =>
builder.AddStandardResilienceHandler());
No retry logic in handlers. No try/catch for transient failures in business code. Exhausted retries throw — caught by global error middleware → Problem Details.
11. Dependency Injection
| Component | Lifetime |
|---|---|
| Handlers | Scoped |
| Validators | Scoped |
| Options classes | Singleton (direct, not IOptions<T>) |
| Infrastructure (Shared) | Singleton |
Infrastructure services must be stateless.
Options Registration
Register the options class directly — consumers inject MyOptions, not IOptions<MyOptions>.
builder.Services.Configure<MyOptions>(builder.Configuration.GetSection("MyOptions"));
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<MyOptions>>().Value);
12. Testing
Tests describe behavior, not implementation. Name tests as plain English assertions.
- Good:
Delivery_with_a_past_date_is_invalid - Bad:
IsDeliveryValid_InvalidDate_ReturnsFalse
Mocking: FakeItEasy. Test project: Project.Tests alongside src/, one file per handler. Never test private methods or use reflection.
13. Engineering Practices
Enforced at project level. No per-file overrides.
| Rule | Mechanism |
|---|---|
| Treat warnings as errors | <TreatWarningsAsErrors>true</TreatWarningsAsErrors> |
| Nullable enabled | <Nullable>enable</Nullable> — no #nullable disable |
| Implicit usings | <ImplicitUsings>enable</ImplicitUsings> |
| File-scoped namespaces | namespace Foo.Bar; only |
| Primary constructors | For DI dependencies |
| Happy path at bottom | Early returns for guard clauses; main logic last |
| Code analysis | <AnalysisMode>Recommended</AnalysisMode> — CA warnings are errors |
.editorconfig |
Repo root. No style debates. |
| Global usings | Shared/GlobalUsings.cs |
Code Smells
Soft signals — not enforced, but triggers for refactoring.
- Methods > 20 lines
- Classes > 200 lines
#region— extract a new class or method instead
14. Hard Rules
- One HTTP request = one command or one query.
- No business logic in Program.cs.
- Endpoints call handlers directly. No dispatcher or mediator.
- Commands throw on failure. Queries return
T?— null means not found. - Every handler explicitly validates, traces, logs, and meters.
- All errors → global middleware → Problem Details. One error model, one conversion point.
- All endpoints authenticated. Fallback policy enforces this.
- Resilience mandatory and centralized. No ad-hoc retries.
- Foundation → Shared only. Domain → Shared + foundation. Domain → domain forbidden.
- New code with warnings does not compile.
15. Mental Model
HTTP Request
└─ Middleware (CORS → Auth → Authorization → Security Headers → Rate Limiting)
└─ Endpoint (binds HTTP → command/query)
└─ Handler
├─ Validate
├─ Trace (span: Feature.Operation)
├─ Log / Meter (start)
├─ Domain logic
├─ Shared services (resilient)
├─ Log / Meter (end)
└─ Exception (if failure)
└─ Global Error Middleware
└─ ProblemDetails