Clean Architecture
Based on RoundTrip Clean Architecture Document v1.0 — March 2026
What is Clean Architecture?
Clean Architecture, described by Robert C. Martin (Uncle Bob) in his 2017 book of the same name, organises software into concentric layers, each with distinct responsibilities. The defining rule is the Dependency Rule: source code dependencies can only point inward — toward higher-level, more abstract layers. Outer layers know about inner layers; inner layers know nothing about outer layers.
The innermost layer — the Domain — contains business rules and has zero dependencies on anything outside itself. The outermost layers — databases, frameworks, external APIs — are implementation details that can be swapped without touching the core business logic.
Why Clean Architecture for RoundTrip?
RoundTrip is a multi-tenant SaaS platform with a 10+ year lifespan. Over that time, the database may change, the API framework may evolve, and third-party services will certainly change. Clean Architecture protects the business logic — the most valuable and hardest-to-replace asset — from coupling to technology choices that will age and need replacement.
| Without Clean Architecture | With Clean Architecture |
|---|---|
| Business rules embedded in API controllers — untestable without spinning up the full HTTP stack | Business rules in the Domain layer — testable with plain xUnit tests, no web server required |
| Swapping SQL Server for a different database requires touching business logic | Database is an infrastructure detail behind a repository interface — swappable without domain changes |
| EF Core changes break domain objects because domain entities have EF attributes | Domain entities are plain C# classes — EF Core configuration lives entirely in Infrastructure |
| Adding a new background job framework requires rewriting handler logic | Handlers are framework-agnostic — Hangfire or any scheduler can call them |
| Unit testing requires mocking the database, HTTP context, and email service simultaneously | Domain layer tests need no mocks — application layer tests mock only the interfaces they use |
:::info The Core Principle The Domain layer is the most important and most stable part of the codebase. It must never know that a database, a web framework, or an external API exists. If you find yourself importing Entity Framework, ASP.NET, or any NuGet package into the Domain project, something has gone wrong. :::
The Four Layers
RoundTrip is structured into four layers. Each layer is a separate .NET project in the solution. Dependencies only flow inward — never outward.
┌─────────────────────────────────────────┐
│ 4. API / Presentation │ ← Outermost
│ FastEndpoints · SignalR · Middleware │
├─────────────────────────────────────────┤
│ 3. Infrastructure │
│ EF Core · Dapper · SendGrid · Graph │
│ Hangfire · Blob Storage · Twilio │
├─────────────────────────────────────────┤
│ 2. Application (Use Cases) │
│ Commands · Queries · Handlers │
│ Pipeline Behaviors · Validators │
├─────────────────────────────────────────┤
│ 1. Domain │ ← Innermost
│ Aggregates · Value Objects │
│ Domain Events · Interfaces │
└─────────────────────────────────────────┘
Dependency Rule: API → Infrastructure → Application → Domain
Domain has NO outward dependencies
Layer Dependency Matrix
| Layer | Can Reference | Cannot Reference | Enforced By |
|---|---|---|---|
| Domain | Nothing external. Pure C# only. | Application, Infrastructure, API, EF Core, ASP.NET, MediatR — anything. | NetArchTest in CI |
| Application | Domain layer only. Cyrus Mediator, FluentValidation, Ardalis.Specification. | Infrastructure, API, EF Core, ASP.NET, SQL Server. | NetArchTest in CI |
| Infrastructure | Application layer, Domain layer. EF Core, SendGrid, Graph API, Twilio. | API layer. FastEndpoints, ASP.NET controller types. | NetArchTest in CI |
| API | Application layer (Commands/Queries). Infrastructure (DI registration only in Program.cs). | Domain layer directly — must go through Application. No direct DbContext access. | NetArchTest in CI + code review |
Solution Structure
RoundTrip.sln
│
├── src/
│ ├── RoundTrip.API.Domain/ ← Layer 1: Domain
│ ├── RoundTrip.API.UseCases/ ← Layer 2: Application
│ ├── RoundTrip.API.Infrastructure/ ← Layer 3: Infrastructure
│ └── RoundTrip.API.Web/ ← Layer 4: API / Presentation
│
└── tests/
├── RoundTrip.Domain.Tests/ ← Unit tests: domain only
├── RoundTrip.Application.Tests/ ← Unit tests: handlers with mocked interfaces
├── RoundTrip.Integration.Tests/ ← Integration tests: real SQL
└── RoundTrip.Architecture.Tests/ ← NetArchTest: enforces layer boundaries
Project Reference Chain
Dependencies only flow inward — this is enforced by the .csproj reference chain:
RoundTrip.API.Web
→ RoundTrip.API.Infrastructure (DI registration only in Program.cs)
→ RoundTrip.API.UseCases
RoundTrip.API.Infrastructure
→ RoundTrip.API.UseCases
RoundTrip.API.UseCases
→ RoundTrip.API.Domain
RoundTrip.API.Domain
→ NO project references — pure C# only
Layer 1: Domain
The Domain layer is the heart of the application. It contains all business rules, domain logic, and the definition of what the application does — completely independent of how it does it.
RoundTrip.API.Domain/
│
├── Common/
│ ├── AggregateRoot.cs
│ ├── Entity.cs
│ ├── IDomainEvent.cs
│ └── Guard.cs
│
├── Aggregates/
│ ├── Tickets/
│ │ ├── ServiceTicket.cs ← Aggregate root
│ │ ├── PartUsage.cs ← Child entity
│ │ ├── TicketNote.cs ← Child entity
│ │ └── TicketAttachment.cs ← Child entity
│ ├── Clients/
│ ├── Inventory/
│ ├── Billing/
│ └── Users/
│
├── ValueObjects/
│ ├── ServiceAddress.cs
│ ├── Money.cs
│ ├── GpsCoordinate.cs
│ ├── TicketNumber.cs
│ └── ...
│
├── Events/
│ ├── Tickets/
│ │ ├── TicketCreatedEvent.cs
│ │ ├── TicketAssignedEvent.cs
│ │ ├── TicketCompletedEvent.cs
│ │ └── PartUsageRecordedEvent.cs
│ └── ...
│
├── Interfaces/
│ └── Repositories/
│ ├── IServiceTicketRepository.cs
│ ├── IClientRepository.cs
│ └── ...
│
└── Enumerations/
├── TicketStatus.cs
├── Priority.cs
└── ...
Domain Layer Rules
| Rule | Detail |
|---|---|
| No NuGet packages | The Domain project has no external NuGet dependencies. Only Microsoft.Extensions.DependencyInjection.Abstractions is permitted. |
| No EF Core attributes | Domain entities never have [Key], [Required], [Column] or any EF Core annotations. All mapping lives in Infrastructure. |
| No async/await in domain methods | Domain methods are synchronous. Async is an infrastructure concern. Aggregate methods like ticket.Complete() are pure synchronous state mutations. |
| Interfaces only for infrastructure contracts | IServiceTicketRepository in the Domain defines what persistence must provide. The EF Core implementation fulfils the contract from Infrastructure. |
| DomainException only | Domain layer only throws DomainException for invariant violations. Never ArgumentException, InvalidOperationException, or any framework exception type. |
Layer 2: Application (Use Cases)
The Application layer orchestrates use cases. It contains commands, queries, handlers, validators, and pipeline behaviors. It depends only on the Domain layer.
RoundTrip.API.UseCases/
│
├── Features/
│ ├── Tickets/
│ │ ├── CreateTicket/
│ │ │ ├── CreateTicketCommand.cs ← IRequest<Result<...>>
│ │ │ ├── CreateTicketHandler.cs ← IRequestHandler<,>
│ │ │ └── CreateTicketValidator.cs
│ │ ├── CompleteTicket/
│ │ ├── GetTicketList/
│ │ │ ├── GetTicketListQuery.cs
│ │ │ ├── GetTicketListHandler.cs ← Uses Dapper for reads
│ │ │ └── TicketListItemDto.cs
│ │ └── ...
│ ├── Clients/
│ ├── Inventory/
│ ├── Billing/
│ └── Users/
│
└── Common/
├── Behaviors/
│ ├── LoggingBehavior.cs
│ ├── ValidationBehavior.cs
│ ├── TenantScopingBehavior.cs
│ └── TransactionBehavior.cs
└── Interfaces/
├── ICurrentUserService.cs
├── ITenantContext.cs
└── ...
Pipeline Behaviors
All cross-cutting concerns execute as pipeline behaviors that wrap every command and query. They run in registration order:
| Behavior | Order | Responsibility |
|---|---|---|
LoggingBehavior | 1st | Logs request type, tenant ID, user ID, and duration |
ValidationBehavior | 2nd | Runs FluentValidation validators — returns 422 if any fail, handler never executes |
TenantScopingBehavior | 3rd | Resolves TenantId, sets ITenantContext. Has IsInitialised guard — do not remove it |
TransactionBehavior | 4th | Begins EF Core transaction, commits on success, rolls back on exception. Dispatches domain events post-commit via IDomainEventPublisher |
:::danger Pipeline Behavior Gotcha
Pipeline behaviors only intercept types that implement IRequest<>. They do not intercept IQuery<>. All command and query records must implement IRequest<TResponse> and handlers must implement IRequestHandler<,>.
:::
Command Handler Pattern
public sealed class CompleteTicketHandler
: IRequestHandler<CompleteTicketCommand, Result<CompleteTicketResponse>>
{
private readonly IServiceTicketRepository _tickets;
private readonly IUnitOfWork _unitOfWork;
public async Task<Result<CompleteTicketResponse>> Handle(
CompleteTicketCommand command,
CancellationToken cancellationToken)
{
// 1. Load the aggregate
var ticket = await _tickets.GetByIdAsync(command.TicketId, cancellationToken);
if (ticket is null) return Result<CompleteTicketResponse>.NotFound();
// 2. Execute domain logic through aggregate method
ticket.Complete(command.CompletionNote);
// 3. Persist — TransactionBehavior handles the transaction wrapper
await _tickets.UpdateAsync(ticket, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// 4. Domain events dispatched post-commit by TransactionBehavior
return Result<CompleteTicketResponse>.Success(
new CompleteTicketResponse(ticket.Id, ticket.Status));
}
}
Query Handler Pattern (Dapper)
Query handlers bypass aggregates entirely and use Dapper for optimised reads:
public sealed class GetTicketListHandler
: IRequestHandler<GetTicketListQuery, Result<PagedList<TicketListItemDto>>>
{
private readonly IDbConnectionFactory _db;
private readonly ITenantContext _tenant;
public async Task<Result<PagedList<TicketListItemDto>>> Handle(
GetTicketListQuery query,
CancellationToken cancellationToken)
{
const string sql = @"
SELECT t.Id, t.Number, c.FirstName + ' ' + c.LastName AS ClientName,
t.ServiceDescription, t.Status, t.Priority,
t.AssignedAt, tech.FirstName + ' ' + tech.LastName AS TechnicianName
FROM Tickets t
JOIN Clients c ON c.Id = t.ClientId
LEFT JOIN Technicians tech ON tech.Id = t.TechnicianId
WHERE t.TenantId = @TenantId
AND (@Status IS NULL OR t.Status = @Status)
ORDER BY t.CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY";
using var conn = _db.CreateConnection();
var items = await conn.QueryAsync<TicketListItemDto>(sql, new {
TenantId = _tenant.TenantId,
Status = query.Status,
Offset = (query.Page - 1) * query.PageSize,
PageSize = query.PageSize,
});
return Result<PagedList<TicketListItemDto>>.Success(
PagedList<TicketListItemDto>.Create(items, query.Page, query.PageSize));
}
}
Layer 3: Infrastructure
Infrastructure implements all the interfaces defined in the Domain and Application layers. It is the only layer that knows about EF Core, Dapper, SendGrid, Azure Blob Storage, Hangfire, etc.
RoundTrip.API.Infrastructure/
│
├── Persistence/
│ ├── AppDbContext.cs
│ ├── Configurations/ ← EF Core IEntityTypeConfiguration<T> per aggregate
│ ├── Repositories/ ← Implements interfaces from Domain
│ └── Dapper/
│ └── SqlConnectionFactory.cs
│
├── Identity/
│ ├── GraphUserService.cs ← Implements IGraphUserService
│ └── CurrentUserService.cs ← Implements ICurrentUserService
│
├── Services/
│ ├── SendGridEmailService.cs
│ └── AzureBlobStorageService.cs
│
└── Jobs/
├── SendInvoiceEmailJob.cs
└── GeocodeClientJob.cs
EF Core Configuration Pattern
Entity Framework configurations are defined as IEntityTypeConfiguration<T> classes — never as data annotations on domain entities:
// In Infrastructure — not Domain
public class ServiceTicketConfiguration : IEntityTypeConfiguration<ServiceTicket>
{
public void Configure(EntityTypeBuilder<ServiceTicket> builder)
{
builder.ToTable("Tickets");
// Value object — owned entity
builder.OwnsOne(t => t.ServiceAddress, addr => {
addr.Property(a => a.Street).HasColumnName("Street").IsRequired();
addr.Property(a => a.City).HasColumnName("City").IsRequired();
addr.Property(a => a.State).HasColumnName("State");
addr.Property(a => a.PostalCode).HasColumnName("PostalCode");
});
// Enum stored as string for readability
builder.Property(t => t.Status)
.HasConversion<string>().HasMaxLength(50);
// Global query filter — automatic tenant scoping
builder.HasQueryFilter(t => t.TenantId == _tenantId);
}
}
Layer 4: API / Presentation
The API layer is thin. Endpoints map HTTP requests to Application commands/queries and map results back to HTTP responses. No business logic lives here.
RoundTrip.API.Web/
│
├── Endpoints/
│ ├── Tickets/
│ │ ├── CreateTicketEndpoint.cs
│ │ ├── CompleteTicketEndpoint.cs
│ │ └── GetTicketListEndpoint.cs
│ └── ...
│
├── Hubs/
│ └── NotificationHub.cs ← SignalR
│
└── Program.cs ← Composition root
FastEndpoint Pattern
// One file, one endpoint — no bloated controllers
public class CompleteTicketEndpoint
: Endpoint<CompleteTicketRequest, CompleteTicketApiResponse>
{
private readonly IMediator _mediator;
public override void Configure()
{
Patch("/v1/tickets/{id}/complete");
Roles("Technician", "Dispatcher", "TenantAdmin");
}
public override async Task HandleAsync(CompleteTicketRequest req, CancellationToken ct)
{
// Map API request → Application command
var command = new CompleteTicketCommand(
TicketId: Route<Guid>("id"),
CompletionNote: req.CompletionNote);
// Send to Mediator pipeline
var result = await _mediator.Send(command, ct);
// Map Result<T> → HTTP response
await SendAsync(result.IsSuccess
? new CompleteTicketApiResponse(result.Value)
: throw new Exception(result.Error), cancellation: ct);
}
}
Architecture Enforcement
Architecture rules are enforced by NetArchTest running in CI on every pull request. If any layer boundary is violated, the build fails before code is merged.
public class ArchitectureTests
{
[Fact]
public void Domain_Should_Not_HaveDependencyOn_AnyOtherLayer()
{
var result = Types.InAssembly(DomainAssembly)
.ShouldNot()
.HaveDependencyOnAny(
ApplicationAssembly.GetName().Name!,
InfrastructureAssembly.GetName().Name!,
ApiAssembly.GetName().Name!)
.GetResult();
result.IsSuccessful.Should().BeTrue(
because: "Domain layer must have no outward dependencies");
}
[Fact]
public void Application_Should_Not_HaveDependencyOn_InfrastructureOrApi()
{
var result = Types.InAssembly(ApplicationAssembly)
.ShouldNot()
.HaveDependencyOnAny(
InfrastructureAssembly.GetName().Name!,
ApiAssembly.GetName().Name!)
.GetResult();
result.IsSuccessful.Should().BeTrue();
}
}
Testing Strategy
| Test Type | Project | What It Tests | Tools |
|---|---|---|---|
| Unit — Domain | RoundTrip.Domain.Tests | Aggregate invariants, domain events, value object validation, state machine transitions | xUnit, FluentAssertions |
| Unit — Application | RoundTrip.Application.Tests | Handler logic, validator rules, pipeline behavior ordering | xUnit, FluentAssertions, NSubstitute |
| Integration | RoundTrip.Integration.Tests | Full command/query flow through real SQL Server, EF Core migrations, tenant isolation | xUnit, Testcontainers, FluentAssertions |
| Architecture | RoundTrip.Architecture.Tests | Layer boundary enforcement, naming conventions, dependency rules | NetArchTest, xUnit |
Related Documents
- Domain-Driven Design — DDD patterns applied within these layers
- Backend Coding — day-to-day coding patterns and conventions
- Cyrus Mediator — deep dive on the mediator pattern used in the Application layer