Skip to main content

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 ArchitectureWith Clean Architecture
Business rules embedded in API controllers — untestable without spinning up the full HTTP stackBusiness rules in the Domain layer — testable with plain xUnit tests, no web server required
Swapping SQL Server for a different database requires touching business logicDatabase is an infrastructure detail behind a repository interface — swappable without domain changes
EF Core changes break domain objects because domain entities have EF attributesDomain entities are plain C# classes — EF Core configuration lives entirely in Infrastructure
Adding a new background job framework requires rewriting handler logicHandlers are framework-agnostic — Hangfire or any scheduler can call them
Unit testing requires mocking the database, HTTP context, and email service simultaneouslyDomain 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

LayerCan ReferenceCannot ReferenceEnforced By
DomainNothing external. Pure C# only.Application, Infrastructure, API, EF Core, ASP.NET, MediatR — anything.NetArchTest in CI
ApplicationDomain layer only. Cyrus Mediator, FluentValidation, Ardalis.Specification.Infrastructure, API, EF Core, ASP.NET, SQL Server.NetArchTest in CI
InfrastructureApplication layer, Domain layer. EF Core, SendGrid, Graph API, Twilio.API layer. FastEndpoints, ASP.NET controller types.NetArchTest in CI
APIApplication 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

RuleDetail
No NuGet packagesThe Domain project has no external NuGet dependencies. Only Microsoft.Extensions.DependencyInjection.Abstractions is permitted.
No EF Core attributesDomain entities never have [Key], [Required], [Column] or any EF Core annotations. All mapping lives in Infrastructure.
No async/await in domain methodsDomain methods are synchronous. Async is an infrastructure concern. Aggregate methods like ticket.Complete() are pure synchronous state mutations.
Interfaces only for infrastructure contractsIServiceTicketRepository in the Domain defines what persistence must provide. The EF Core implementation fulfils the contract from Infrastructure.
DomainException onlyDomain 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:

BehaviorOrderResponsibility
LoggingBehavior1stLogs request type, tenant ID, user ID, and duration
ValidationBehavior2ndRuns FluentValidation validators — returns 422 if any fail, handler never executes
TenantScopingBehavior3rdResolves TenantId, sets ITenantContext. Has IsInitialised guard — do not remove it
TransactionBehavior4thBegins 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 TypeProjectWhat It TestsTools
Unit — DomainRoundTrip.Domain.TestsAggregate invariants, domain events, value object validation, state machine transitionsxUnit, FluentAssertions
Unit — ApplicationRoundTrip.Application.TestsHandler logic, validator rules, pipeline behavior orderingxUnit, FluentAssertions, NSubstitute
IntegrationRoundTrip.Integration.TestsFull command/query flow through real SQL Server, EF Core migrations, tenant isolationxUnit, Testcontainers, FluentAssertions
ArchitectureRoundTrip.Architecture.TestsLayer boundary enforcement, naming conventions, dependency rulesNetArchTest, xUnit