Skip to content

Clean Architecture

This document explains how Clean Architecture is applied across all projects following these standards. Every project follows this structure regardless of domain complexity.

Agent Quick Rules {#agent-quick-rules}

  • Dependencies MUST point inward: Domain has no infrastructure references.
  • WebApi references Contracts projects only, not Write/Read implementation projects.
  • Command handlers live in Application.Write; query handlers in Application.Read.
  • Query handlers MUST use IDatabaseContext; MUST NOT inject repository interfaces.
  • Reactions handle domain events with narrow side-effect interfaces only.
  • Infrastructure implements repositories, EF Core, pipeline, and external clients.
  • Endpoints use IEndpoint; MUST NOT use MVC controllers.

Full convention: docs/architecture/clean-architecture.md

Key decisions documented in docs/decisions/: docs/decisions/clean-architecture-as-structural-foundation.md (Clean Architecture as foundation), docs/decisions/cqrs-with-split-application-projects.md (CQRS project split), docs/decisions/minimal-api-endpoint-classes.md (IEndpoint pattern), docs/decisions/idatabasecontext-over-per-aggregate-read-stores.md (IDatabaseContext), docs/decisions/transaction-pipeline-behaviors.md (Transaction pipeline behaviors).


1. Layer Diagram

graph TD
WebApi["WebApi\n(Endpoints, Request/Response Models, ApiMappings)"]
WriteContracts["Application.Write.Contracts\n(Commands, Command Results, CommandValidationException)"]
ReadContracts["Application.Read.Contracts\n(Queries, Query Results, IDatabaseContext, PagedResult<T>, QueryValidationException)"]
Write["Application.Write\n(Command Handlers, Validators)"]
Read["Application.Read\n(Query Handlers, Validators)\nReferences Microsoft.EntityFrameworkCore"]
Reactions["Application.Reactions\n(Event Handlers, Narrow Interfaces)"]
Domain["Domain\n(Aggregates, Value Objects, Events, Repository Interfaces)"]
Infrastructure["Infrastructure\n(EF Core, Repositories, AppDbContext implements IDatabaseContext,\nTransaction Pipeline Behaviors, External Clients)"]
WebApi --> WriteContracts
WebApi --> ReadContracts
Write --> WriteContracts
Write --> Domain
Read --> ReadContracts
Read --> Domain
Reactions --> WriteContracts
Reactions --> ReadContracts
Reactions --> Domain
Infrastructure --> Domain
Infrastructure --> WriteContracts
Infrastructure --> ReadContracts
Infrastructure --> Reactions

2. Layer Responsibilities

Domain

The Domain layer contains the core business model. It has zero dependencies on any other project in the solution and no dependency on persistence, web, UI, or messaging frameworks. It may use the .NET BCL and the approved guard clause package. Its aggregate shape is deliberately compatible with EF Core materialisation, but the Domain project does not reference EF Core.

Contains:

  • Aggregate roots and their child entities
  • Value objects
  • Domain events
  • Domain exceptions (DomainException subclasses, AggregateNotFoundException subclasses)
  • Repository interfaces (IPostRepository, ICustomerRepository, etc.)
  • Strongly-typed IDs (PostId, CustomerId, etc.)

Forbidden:

  • Any reference to Microsoft.EntityFrameworkCore
  • Any reference to Microsoft.AspNetCore.*
  • Application models, DTOs, or read projection types
  • Infrastructure concerns: connection strings, HTTP clients, file paths

Private parameterless constructors and private collection backing fields are permitted in aggregate roots even though they exist primarily for EF Core materialisation compatibility. The compatibility is structural, not a package dependency.

Application.Write.Contracts

The public contract of the write path. Contains only types that form the API surface of write operations.

Contains:

  • Command record types (CreatePostCommand, PublishPostCommand)
  • Command result record types (PostId as a result, or dedicated result records)
  • Write-side contract interfaces that Infrastructure implements
  • NuGet reference: LiteBus.Commands.Abstractions

Forbidden:

  • Command handler implementations
  • Command validator implementations
  • Any business logic

Application.Write

The private implementation of the write path.

Contains:

  • Command handler implementations (ICommandHandler<TCommand>, ICommandHandler<TCommand, TResult>)
  • Command validator implementations (ICommandValidator<TCommand>)
  • References: Application.Write.Contracts, Domain
  • NuGet reference: LiteBus.Commands.Abstractions

Forbidden:

  • Command or query type definitions (those belong in Contracts)
  • Read store interfaces or query types
  • Any reference to Microsoft.EntityFrameworkCore

Application.Read.Contracts

The public contract of the read path. Contains only types that form the API surface of read operations.

Contains:

  • Query record types (GetPostByIdQuery)
  • Query result record types (PostResult, PostSummary)
  • IDatabaseContext interface exposing IQueryable<T> per aggregate
  • PagedResult<T> and PaginationParameters shared pagination types
  • QueryValidationException base class
  • NuGet reference: LiteBus.Queries.Abstractions

Forbidden:

  • Query handler implementations
  • Query validator implementations
  • Any business logic

Application.Read

The private implementation of the read path.

Contains:

  • Query handler implementations (IQueryHandler<TQuery, TResult>)
  • Query validator implementations (IQueryValidator<TQuery>)
  • References: Application.Read.Contracts, Domain, Microsoft.EntityFrameworkCore
  • NuGet reference: LiteBus.Queries.Abstractions
  • Injects IDatabaseContext for all data access

Forbidden:

  • Query or result type definitions (those belong in Contracts)
  • Any reference to the Infrastructure project directly (use IDatabaseContext from Application.Read.Contracts)

Application.Reactions

Event handler implementations that react to domain events.

Contains:

  • Event handler implementations (IEventHandler<TEvent>)
  • Narrow, per-handler interfaces for external side effects (IPostPublishedNotifier)
  • References: Application.Write.Contracts, Application.Read.Contracts, Domain
  • NuGet references: LiteBus.Events.Abstractions; LiteBus.Commands.Abstractions only when a handler dispatches follow-up commands

Forbidden:

  • Any reference to external library packages (EF Core, email clients, HTTP clients)
  • Any business rule enforcement (that belongs in Domain)

Infrastructure

Adapts external systems to the interfaces defined by Domain and Application.

Contains:

  • EF Core DbContext implementation (AppDbContext implements IDatabaseContext)
  • IEntityTypeConfiguration<T> classes for all aggregates
  • IXxxRepository implementations
  • Global LiteBus pipeline behaviors: TransactionCommandPreHandler, SaveChangesCommandPostHandler, RollbackCommandErrorHandler
  • Implementations of narrow interfaces defined in Application.Reactions
  • External service clients (email, payment, blob storage)
  • EF Core migrations
  • DI registration extension methods

Forbidden:

  • Business logic or domain rule enforcement
  • Knowledge of the HTTP request lifecycle
  • References to Microsoft.AspNetCore.* except for DI types

WebApi

The HTTP adapter. Translates HTTP requests into application commands and queries.

Contains:

  • IEndpoint implementations (one class per use case)
  • Request and response record types
  • ApiMappings extension classes
  • GlobalExceptionHandler middleware
  • OpenAPI configuration

Forbidden:

  • Business logic of any kind
  • Direct access to repositories, IDatabaseContext, or DbContext
  • try-catch blocks (handled by GlobalExceptionHandler)
  • Domain types in response models

3. Dependency Rule

Dependencies point inward only. No inner layer may reference an outer layer.

graph LR
Domain["Domain"]
WriteContracts["App.Write.Contracts"]
ReadContracts["App.Read.Contracts"]
Write["App.Write"]
Read["App.Read"]
Reactions["App.Reactions"]
Infra["Infrastructure"]
WebApi["WebApi"]
WriteContracts --> Domain
ReadContracts --> Domain
Write --> WriteContracts
Write --> Domain
Read --> ReadContracts
Read --> Domain
Reactions --> WriteContracts
Reactions --> ReadContracts
Reactions --> Domain
Infra --> Domain
Infra --> WriteContracts
Infra --> ReadContracts
Infra --> Reactions
WebApi --> WriteContracts
WebApi --> ReadContracts
WebApi -.->|"Program.cs only"| Infra
WebApi -.->|"Program.cs only"| Write
WebApi -.->|"Program.cs only"| Read
WebApi -.->|"Program.cs only"| Reactions
ProjectMay Reference
DomainNothing
Application.Write.ContractsDomain
Application.Read.ContractsDomain
Application.WriteApplication.Write.Contracts, Domain
Application.ReadApplication.Read.Contracts, Domain, Microsoft.EntityFrameworkCore
Application.ReactionsApplication.Write.Contracts, Application.Read.Contracts, Domain
InfrastructureDomain, Application.Write.Contracts, Application.Read.Contracts, Application.Reactions
WebApiApplication.Write.Contracts, Application.Read.Contracts
WebApi (Program.cs only)Infrastructure, Application.Write, Application.Read, Application.Reactions for DI registration

4. CQRS Split

Commands and queries are handled by separate classes in separate projects. This enforces a hard split between the read path and the write path at the compiler level.

Commands modify state. A command handler:

  1. Validates input (a separate validator runs before the handler via LiteBus pipeline).
  2. Loads the aggregate from its repository.
  3. Calls a method on the aggregate that enforces the business rule.
  4. Saves the aggregate back via the repository.
  5. Returns void or a simple creation result.

Queries return data. A query handler:

  1. Validates input (a separate validator runs before the handler).
  2. Writes a LINQ projection against IDatabaseContext.
  3. Returns the projection result or throws an AggregateNotFoundException subclass if not found.

Query handlers MUST NOT load domain aggregates. They MUST NOT inject repository interfaces. See docs/conventions/backend/query-read-strategy.md for full details.


5. LiteBus as Mediator

LiteBus is a modular mediator. Each project references only the package it needs, not the full metapackage. This keeps project dependencies minimal and explicit.

LiteBus PackageProjectPurpose
LiteBus.Commands.AbstractionsApplication.Write.Contracts, Application.Write, Application.ReactionsICommand, ICommandHandler, ICommandValidator, ICommandMediator
LiteBus.Queries.AbstractionsApplication.Read.Contracts, Application.ReadIQuery, IQueryHandler, IQueryValidator, IQueryMediator
LiteBus.Events.AbstractionsApplication.ReactionsIEvent, IEventHandler, IEventPublisher
LiteBus.Extensions.Microsoft.DependencyInjectionWebApiFull DI registration
LiteBus.Commands.AbstractionsInfrastructureICommandPreHandler<ICommand>, ICommandPostHandler<ICommand>, ICommandErrorHandler<ICommand> for global pipeline behaviors

Endpoints dispatch via ICommandMediator or IQueryMediator. These interfaces are the specific entry points for endpoints. WebApi does not reference the handler implementation projects directly in endpoint code; it references only the Contracts projects for the command and query types.

// GOOD: endpoint dispatches via specific mediator, references only Contracts types
sealed class CreatePostEndpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapPost("/posts", HandleAsync);
}
private static async Task<IResult> HandleAsync(
CreatePostRequest request,
ICommandMediator commandMediator,
CancellationToken cancellationToken)
{
var command = request.ToCommand();
var postId = await commandMediator.SendAsync(command, cancellationToken);
return Results.Created($"/posts/{postId.Value}", postId.ToResponse());
}
}

6. Event Handling and the Reactions Project

Domain events originate in aggregates. An aggregate method raises a domain event by calling RaiseDomainEvent(new PostPublished(Id)). Infrastructure collects these events during the command pipeline and dispatches them after the aggregate changes are committed, or writes them to the outbox when durable delivery is required.

The Application.Reactions project contains event handlers. Each handler reacts to one domain event. There are two default categories:

  1. Dispatches a follow-up command: The handler receives an event and sends a command via ICommandMediator. Use this only when eventual consistency is acceptable and the command is idempotent.
  2. Triggers an external side effect through a narrow interface: The handler receives an event and calls a project-owned interface such as IPostPublishedNotifier.

Read model projection updates are not a default Reactions category. Query handlers read through IDatabaseContext projections by default. If a project needs denormalized read model tables, document the projection ownership in a project ADR and use the outbox pattern when stale projections are not acceptable.

The Reactions project MUST NOT reference external libraries. All external capabilities are accessed through narrow interfaces defined in the Reactions project and implemented by Infrastructure.

// GOOD: narrow interface defined in Application.Reactions
internal interface IPostPublishedNotifier
{
Task NotifySubscribersAsync(PostId postId, string postTitle, CancellationToken cancellationToken);
}
// GOOD: event handler uses the narrow interface
internal sealed class NotifySubscribersOnPostPublishedEventHandler : IEventHandler<PostPublished>
{
private readonly IPostPublishedNotifier _notifier;
public NotifySubscribersOnPostPublishedEventHandler(
IPostPublishedNotifier notifier)
{
_notifier = notifier;
}
public async Task HandleAsync(PostPublished @event, CancellationToken cancellationToken)
{
await _notifier.NotifySubscribersAsync(
@event.PostId,
@event.PostTitle,
cancellationToken);
}
}
// BAD: event handler references an external library directly
internal sealed class NotifySubscribersOnPostPublishedEventHandler : IEventHandler<PostPublished>
{
private readonly IEmailClient _emailClient; // BAD: external library in Application.Reactions
}

7. Transaction Pipeline Behaviors

Transaction management is handled by three global LiteBus pipeline behaviors registered in Infrastructure. Command handlers do not call SaveChangesAsync. Repositories do not call SaveChangesAsync. The pipeline handles all persistence.

The execution order for every command:

sequenceDiagram
participant Mediator
participant Validator as ICommandValidator<T><br/>(priority 0)
participant TxPre as TransactionCommandPreHandler<br/>(priority 10)
participant Handler as ICommandHandler<T>
participant TxPost as SaveChangesCommandPostHandler
participant ErrorH as RollbackCommandErrorHandler
Mediator->>Validator: PreHandleAsync (validate input)
alt Validation fails
Validator-->>Mediator: throws CommandValidationException
end
Mediator->>TxPre: PreHandleAsync (begin transaction)
Mediator->>Handler: HandleAsync (business logic)
alt Handler throws
Handler-->>ErrorH: exception
ErrorH->>ErrorH: RollbackTransactionAsync
ErrorH-->>Mediator: re-throws
end
Mediator->>TxPost: PostHandleAsync (SaveChanges + Commit)

Handler Priority Scheme

PriorityHandlerScope
0 (default)ICommandValidator<TCommand>Specific per command; runs before the transaction opens
10TransactionCommandPreHandlerGlobal for all ICommand; opens transaction after validation

Implementation

Full handler implementations, assembly marker classes, and LiteBus registration live in docs/conventions/backend/infrastructure-layer.md (Transaction Pipeline Behaviors and LiteBus Registration sections). This document describes the pattern; the Infrastructure convention is authoritative for code.

LiteBus Registration

Register LiteBus in WebApi/Program.cs only. See docs/blueprints/backend/program-cs.md for the complete composition root. Do not duplicate registration in other convention files.

Handler classes are internal sealed. Each implementation project exposes a public static class {Layer}AssemblyMarker { } so Program.cs can reference the assembly without importing internal types. See docs/conventions/backend/infrastructure-layer.md for assembly marker examples.


8. Architecture Tests

Structural rules are enforced by architecture tests using NetArchTest in addition to project reference constraints. Project references prevent the most obvious violations. Architecture tests catch violations that project references cannot.

Architecture tests live in {ProjectName}.Architecture.Tests. They run in CI on every PR. See docs/conventions/backend/testing.md for full examples.

Three concrete examples of rules that architecture tests enforce:

  1. Query handlers must not depend on repository interfaces. The project reference alone does not prevent this if a future refactoring introduces a shared project containing both. The test explicitly asserts no such dependency exists.

  2. Handlers must be internal sealed. Handlers are implementation details and must not be public. The test asserts this for all types implementing ICommandHandler<,> or IQueryHandler<,>.

  3. Reactions must not reference external libraries. NetArchTest checks that no type in the Reactions assembly has a dependency on Microsoft.EntityFrameworkCore or similar packages.


9. The AggregateRoot Base Class

Every project defines three types in Domain/Shared/. They are not provided by a NuGet package; they are owned by the project.

Domain/Shared/IAggregateRoot.cs
/// <summary>
/// Non-generic marker interface implemented by all aggregate roots.
/// Used by Infrastructure to query the EF Core change tracker without
/// knowing the concrete ID type.
/// </summary>
public interface IAggregateRoot
{
IReadOnlyList<IDomainEvent> DomainEvents { get; }
void ClearDomainEvents();
}
/// <summary>
/// The base class for all aggregate roots. Provides domain event collection
/// and the strongly-typed ID contract.
/// </summary>
abstract class AggregateRoot<TId> : IAggregateRoot
where TId : struct
{
private readonly List<IDomainEvent> _domainEvents = [];
/// <summary>
/// The unique identifier of this aggregate root.
/// </summary>
public TId Id { get; protected set; } // set, not init — required for EF Core materialisation
/// <summary>
/// Domain events raised during this aggregate's lifetime, dispatched
/// after the transaction commits.
/// </summary>
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
/// <summary>
/// Records a domain event to be dispatched after the transaction commits.
/// </summary>
protected void RaiseDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
/// <summary>
/// Clears all recorded domain events. Called by Infrastructure after
/// events have been dispatched.
/// </summary>
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}

The IDomainEvent marker interface and visibility rules are defined in docs/conventions/backend/domain-layer.md (Communication via Domain Events). That document is authoritative for event naming, structure, and the public visibility requirement.

All three types live in Domain/Shared/. All aggregate roots extend AggregateRoot<TId>. All domain event records implement IDomainEvent. Infrastructure calls ClearDomainEvents() after dispatching events via LiteBus.