Skip to content

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:

PatternDescriptionPrevious Experience
Anemic domain modelDomain objects are data bags; logic in service classesFamiliar to most developers; hard to maintain at scale
Transaction ScriptProcedural: one method per use caseSimple for small services; collapses under complexity
Clean ArchitectureDependency inversion, domain at centre, CQRSUsed in 2 prior enterprise projects; well-documented
Vertical Slice ArchitectureFeature folders, no strict layer separationEmerging; 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.cs

CQRS 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)

OperationName
CreateAdd{Entity}Command
UpdateEdit{Entity}Command
DeleteDelete{Entity}Command
Get by IDGetById{Entity}Query
Get paginated listGetAll{Entity}Query
Get dropdownGetDropdown{Entity}Query
ExportExport{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}Query that 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
});
BehaviourOrderPurpose
ValidationBehavior<T>1stRuns FluentValidation; throws ValidationException on failure
LoggingBehavior<T>2ndStructured request/response logging
PerformanceBehavior<T>3rdWarns 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.


  • 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)

Internal Documentation — Microtec Platform Team