Appearance
ADR-006: Clean Architecture + CQRS for Backend Services
Status
Accepted
Date
2023-Q2
Context
With the microservices decision made (ADR-001), each service still needed an internal architectural pattern. The team had experience with several patterns from previous projects:
| Pattern | Description | Previous Experience |
|---|---|---|
| Anemic domain model | Domain objects are data bags; logic in service classes | Familiar to most developers; hard to maintain at scale |
| Transaction Script | Procedural: one method per use case | Simple for small services; collapses under complexity |
| Clean Architecture | Dependency inversion, domain at centre, CQRS | Used in 2 prior enterprise projects; well-documented |
| Vertical Slice Architecture | Feature folders, no strict layer separation | Emerging; less tooling maturity in 2023 |
The ERP domain is rich: invoicing rules, tax calculations, approval workflows, multi-currency, multi-tenant isolation. A flat service layer would not sustain this complexity as modules grow.
Additionally, the team wanted to enforce a consistent structure across 10+ microservices so that any engineer could navigate any service without prior context.
Decision
Apply Clean Architecture with CQRS (via MediatR) as the standard internal structure for every backend microservice.
Layer Structure
Every service follows this exact layout:
{ServiceName}/
├── {ServiceName}.Domain/ ← Pure business logic; zero external deps
│ ├── Entities/
│ ├── ValueObjects/
│ ├── Events/ ← Domain events
│ └── Exceptions/ ← DomainException, NotFoundException
│
├── {ServiceName}.Application/ ← Use cases; depends only on Domain
│ ├── {Feature}/
│ │ ├── Commands/
│ │ │ ├── Add/
│ │ │ │ ├── AddInvoiceCommand.cs
│ │ │ │ ├── AddInvoiceCommandHandler.cs
│ │ │ │ └── AddInvoiceCommandValidator.cs
│ │ │ └── Edit/
│ │ └── Queries/
│ │ ├── GetAll/
│ │ └── GetById/
│ └── Common/ ← DTOs, shared behaviours
│
├── {ServiceName}.Infrastructure/ ← EF Core, external HTTP calls, file storage
│ ├── Persistence/
│ │ ├── DbContext.cs
│ │ └── Configurations/ ← IEntityTypeConfiguration<T>
│ └── ExternalServices/
│
└── {ServiceName}.Apis/ ← ASP.NET Core controllers; thin layer
├── Controllers/
└── Program.csCQRS with MediatR
Every operation is a command or query handled by a dedicated handler class:
Command: write operation (creates, updates, deletes state)
Query: read operation (never mutates state)Controllers dispatch exclusively through MediatR — no business logic in controllers:
csharp
[HttpPost]
[ProducesResponseType<int>(StatusCodes.Status200OK)]
public async Task<IActionResult> AddInvoice(AddInvoiceCommand command)
=> Ok(await _mediator.Send(command));Dependency Rule
Dependencies flow inward only:
Apis → Infrastructure → Application → Domain
↑ ↑
(packages) (packages)The Domain layer has zero dependencies on any NuGet package except Microtec.Domain base classes. The Application layer knows nothing about EF Core or HTTP — it defines interfaces that Infrastructure implements.
Naming Convention (strictly enforced)
| Operation | Name |
|---|---|
| Create | Add{Entity}Command |
| Update | Edit{Entity}Command |
| Delete | Delete{Entity}Command |
| Get by ID | GetById{Entity}Query |
| Get paginated list | GetAll{Entity}Query |
| Get dropdown | GetDropdown{Entity}Query |
| Export | Export{Entity}Query |
Consequences
Positive
- Predictable structure: Any engineer can find any feature by navigating
Application/{Feature}/Commands/or/Queries/— regardless of which service they are in - Testable by design: Application layer has no infrastructure dependencies; command handlers are tested with in-memory repositories and mocked interfaces
- Domain integrity: Domain entities use private setters and factory methods — the domain cannot be put into an invalid state from outside
- Pipeline behaviours: MediatR
IPipelineBehavior<T>enables cross-cutting concerns (logging, validation, performance monitoring) without touching handlers - Specification pattern: Read queries use
ISpecification<T>— complex filters stay in one place and are composable - Clear ownership: Each feature folder is a self-contained vertical slice within the layer structure — easy to assign ownership to a developer
Negative
- More files per feature: A simple CRUD operation produces 3–5 files (Command, Handler, Validator, DTO, Controller action). Teams unfamiliar with the pattern find this excessive initially.
- Mapping overhead: Data must be mapped between Domain entities, Application DTOs, and API response models. AutoMapper is used to reduce boilerplate.
- CQRS friction for simple reads: A
GetAll{Entity}Querythat maps directly from a DB table through CQRS adds indirection that a direct EF query would not. Justified by consistency — developers always know where to look. - Domain richness requires upfront design: Rich domain entities with private setters require thinking about invariants before writing code. Teams must resist the pull toward anemic models.
Neutral
- MediatR was chosen over a hand-rolled dispatcher for its pipeline behaviour support and broad .NET community familiarity
- FluentValidation handles all input validation in the Application layer via
AbstractValidator<TCommand> - AutoMapper handles DTO mapping; mapping profiles live in
Application/Common/MappingProfiles/
Implementation Notes
Pipeline Behaviours (registered globally)
csharp
// Program.cs
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
cfg.AddMicrotecBehaviors(); // Adds: Validation → Logging → Performance
});| Behaviour | Order | Purpose |
|---|---|---|
ValidationBehavior<T> | 1st | Runs FluentValidation; throws ValidationException on failure |
LoggingBehavior<T> | 2nd | Structured request/response logging |
PerformanceBehavior<T> | 3rd | Warns when handler exceeds 500ms |
Domain Entity Example
csharp
// Rich entity — private setters, factory method, domain events
public class Invoice : TenantEntity
{
public string Number { get; private set; } = default!;
public decimal Total { get; private set; }
public InvoiceStatus Status { get; private set; } = InvoiceStatus.Draft;
private Invoice() { } // EF Core needs a parameterless constructor
public static Invoice Create(string number, decimal total)
{
Guard.Against.NullOrEmpty(number, nameof(number));
Guard.Against.NegativeOrZero(total, nameof(total));
var invoice = new Invoice { Number = number, Total = total };
invoice.AddDomainEvent(new InvoiceCreatedDomainEvent(invoice.Id));
return invoice;
}
public void Approve()
{
if (Status != InvoiceStatus.Draft)
throw new DomainException("Only draft invoices can be approved.");
Status = InvoiceStatus.Approved;
AddDomainEvent(new InvoiceApprovedDomainEvent(Id));
}
}Reference Implementation
The Accounting module (Platforms/Src/AppsPortal/Accounting/) is the most complete and most mature reference. New engineers are directed to study it before starting work in any other module.
Alternatives Reconsidered
Vertical Slice Architecture (VSA) was reconsidered at the 12-month mark. The team concluded:
- Clean Architecture's explicit layer separation better suits the ERP domain's complexity
- VSA's cross-cutting concerns (multi-tenancy, audit, validation) are harder to enforce consistently across feature slices
- The existing NuGet packages (
Microtec.Persistence,Microtec.Web.Core) are designed around the Clean Architecture layer model
VSA may be revisited for future greenfield services with genuinely simple domains.
Related ADRs
- ADR-001: Microservices Architecture (each microservice uses Clean Architecture internally)
- ADR-005: Azure Service Bus (integration events are published from Domain via Application layer)
- ADR-009: NuGet Decomposition (packages map to Clean Architecture layers)