Skip to content

Clean Architecture & CQRS

All Microtec backend microservices follow Clean Architecture as codified by Robert C. Martin, combined with CQRS (Command Query Responsibility Segregation) implemented via MediatR. This page describes the layer model, folder conventions, handler patterns, and MediatR pipeline.


The Four Layers

Layer Responsibilities

LayerProject SuffixKey ContentsDependencies
Domain.DomainEntities, Value Objects, Domain Events, IMultiTenantEntity, Repository interfacesNone
Application.ApplicationCQRS Command/Query/Handlers, FluentValidation validators, DTOs, service interfacesDomain only
Infrastructure.InfrastructureEF Core DbContext, EF migrations, AutoMapper profiles, external HTTP clientsApplication + Domain
Presentation.ApisASP.NET Core controllers, Swagger config, auth middleware, health checksApplication only

The Dependency Rule

Source code dependencies must point inward only. Inner layers must never reference outer layers. The Domain layer has zero NuGet dependencies on other layers. The Application layer depends on Domain only. Violations of this rule are caught by a custom Roslyn analyzer included in Microtec.Domain.


CQRS with MediatR

Every state-changing operation is a Command; every data-retrieval operation is a Query. They share no model and are handled independently.

Base Types

csharp
// In Microtec.Application.Core package

/// <summary>Write operation — returns TResponse (often Result or entity ID)</summary>
public interface ICommand<TResponse> : IRequest<TResponse> { }

/// <summary>Read operation — returns TResponse (DTO or list)</summary>
public interface IQuery<TResponse> : IRequest<TResponse> { }

/// <summary>Handler for a Command</summary>
public interface ICommandHandler<TCommand, TResponse>
    : IRequestHandler<TCommand, TResponse>
    where TCommand : ICommand<TResponse> { }

/// <summary>Handler for a Query</summary>
public interface IQueryHandler<TQuery, TResponse>
    : IRequestHandler<TQuery, TResponse>
    where TQuery : IQuery<TResponse> { }

Command Example: Create Invoice

csharp
// Application/Invoice/Commands/AddInvoice/AddInvoiceCommand.cs

public record AddInvoiceCommand(
    string InvoiceNumber,
    Guid CustomerId,
    DateOnly InvoiceDate,
    List<InvoiceLineDto> Lines
) : ICommand<Guid>;

// Application/Invoice/Commands/AddInvoice/AddInvoiceCommandHandler.cs

internal sealed class AddInvoiceCommandHandler
    : ICommandHandler<AddInvoiceCommand, Guid>
{
    private readonly IUnitOfWork<AccountingDbContext> _unitOfWork;
    private readonly IClockService _clock;

    public AddInvoiceCommandHandler(
        IUnitOfWork<AccountingDbContext> unitOfWork,
        IClockService clock)
    {
        _unitOfWork = unitOfWork;
        _clock = clock;
    }

    public async Task<Guid> Handle(
        AddInvoiceCommand request,
        CancellationToken cancellationToken)
    {
        var invoice = Invoice.Create(
            request.InvoiceNumber,
            request.CustomerId,
            request.InvoiceDate,
            _clock.UtcNow);

        foreach (var line in request.Lines)
            invoice.AddLine(line.ItemId, line.Quantity, line.UnitPrice);

        await _unitOfWork.Repository<Invoice>().AddAsync(invoice, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return invoice.Id;
    }
}

Query Example: Get Invoice by ID

csharp
// Application/Invoice/Queries/GetByIdInvoice/GetByIdInvoiceQuery.cs

public record GetByIdInvoiceQuery(Guid InvoiceId) : IQuery<InvoiceDetailDto>;

// Application/Invoice/Queries/GetByIdInvoice/GetByIdInvoiceQueryHandler.cs

internal sealed class GetByIdInvoiceQueryHandler
    : IQueryHandler<GetByIdInvoiceQuery, InvoiceDetailDto>
{
    private readonly IReadRepository<Invoice> _repo;
    private readonly IMapper _mapper;

    public GetByIdInvoiceQueryHandler(
        IReadRepository<Invoice> repo,
        IMapper mapper)
    {
        _repo = repo;
        _mapper = mapper;
    }

    public async Task<InvoiceDetailDto> Handle(
        GetByIdInvoiceQuery request,
        CancellationToken cancellationToken)
    {
        var invoice = await _repo
            .AsNoTracking()
            .Include(i => i.Lines)
            .FirstOrDefaultAsync(i => i.Id == request.InvoiceId, cancellationToken)
            ?? throw new NotFoundException(nameof(Invoice), request.InvoiceId);

        return _mapper.Map<InvoiceDetailDto>(invoice);
    }
}

AsNoTracking on Queries

All Query handlers must call AsNoTracking(). EF Core tracking is only needed for commands that modify data. This convention is enforced in code review.


MediatR Pipeline Behaviours

Every IRequest dispatched via _mediator.Send() passes through three pipeline behaviours in order. These are registered globally — no per-handler wiring required.

ValidationBehaviour

csharp
public class ValidationBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators)
        => _validators = validators;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);
        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Count != 0)
            throw new ValidationException(failures);

        return await next();
    }
}

Folder Structure Convention

Every feature follows this exact folder layout. Consistency enables developers to navigate any service without prior knowledge.

{ServiceName}.Application/
└── {FeatureName}/
    ├── Commands/
    │   ├── Add{FeatureName}/
    │   │   ├── Add{FeatureName}Command.cs
    │   │   ├── Add{FeatureName}CommandHandler.cs
    │   │   └── Add{FeatureName}CommandValidator.cs
    │   ├── Edit{FeatureName}/
    │   │   ├── Edit{FeatureName}Command.cs
    │   │   ├── Edit{FeatureName}CommandHandler.cs
    │   │   └── Edit{FeatureName}CommandValidator.cs
    │   └── Delete{FeatureName}/
    │       ├── Delete{FeatureName}Command.cs
    │       └── Delete{FeatureName}CommandHandler.cs
    └── Queries/
        ├── GetById{FeatureName}/
        │   ├── GetById{FeatureName}Query.cs
        │   └── GetById{FeatureName}QueryHandler.cs
        ├── GetAll{FeatureName}/
        │   ├── GetAll{FeatureName}Query.cs
        │   └── GetAll{FeatureName}QueryHandler.cs
        ├── GetDropdown{FeatureName}/
        │   ├── GetDropdown{FeatureName}Query.cs
        │   └── GetDropdown{FeatureName}QueryHandler.cs
        └── Export{FeatureName}/
            ├── Export{FeatureName}Query.cs
            └── Export{FeatureName}QueryHandler.cs

Naming Conventions

OperationCommand/Query Class NameHTTP VerbRoute
CreateAdd{Entity}CommandPOST/api/v1/{entities}
UpdateEdit{Entity}CommandPUT/api/v1/{entities}/{id}
DeleteDelete{Entity}CommandDELETE/api/v1/{entities}/{id}
Get by IDGetById{Entity}QueryGET/api/v1/{entities}/{id}
Get all (paged)GetAll{Entity}QueryGET/api/v1/{entities}
Dropdown listGetDropdown{Entity}QueryGET/api/v1/{entities}/dropdown
ExportExport{Entity}QueryGET/api/v1/{entities}/export

Naming is Enforced

Deviating from this naming convention causes confusion during code review and breaks the search-based navigation pattern. All PRs are reviewed for naming compliance.


Controller Pattern

csharp
[ApiController]
[Route("api/v1/[controller]")]
[Authorize]
public class InvoicesController : ControllerBase
{
    private readonly IMediator _mediator;

    public InvoicesController(IMediator mediator)
        => _mediator = mediator;

    [HttpPost]
    [ProducesResponseType<Guid>(StatusCodes.Status201Created)]
    [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Add(
        [FromBody] AddInvoiceCommand command,
        CancellationToken cancellationToken)
    {
        var id = await _mediator.Send(command, cancellationToken);
        return CreatedAtAction(nameof(GetById), new { id }, id);
    }

    [HttpGet("{id:guid}")]
    [ProducesResponseType<InvoiceDetailDto>(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(
        Guid id,
        CancellationToken cancellationToken)
    {
        var result = await _mediator.Send(
            new GetByIdInvoiceQuery(id), cancellationToken);
        return Ok(result);
    }
}

Key rules:

  • [ProducesResponseType] must be on every action (required for Swagger accuracy).
  • Use nameof() for route strings, never magic strings.
  • Controller methods should be 5 lines or fewer — all logic lives in the handler.
  • Inject only IMediator; never inject domain services directly into controllers.

Domain Entity Pattern

csharp
// Domain/Entities/Invoice.cs

public sealed class Invoice : BaseEntity, IMultiTenantEntity
{
    public string InvoiceNumber { get; private set; } = default!;
    public Guid CustomerId { get; private set; }
    public DateOnly InvoiceDate { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public Guid TenantId { get; private set; }   // from IMultiTenantEntity

    private readonly List<InvoiceLine> _lines = [];
    public IReadOnlyCollection<InvoiceLine> Lines => _lines.AsReadOnly();

    private Invoice() { }  // Required by EF Core

    public static Invoice Create(
        string invoiceNumber,
        Guid customerId,
        DateOnly invoiceDate,
        DateTime createdAt)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(invoiceNumber);
        return new Invoice
        {
            InvoiceNumber = invoiceNumber,
            CustomerId = customerId,
            InvoiceDate = invoiceDate,
            CreatedAt = createdAt
        };
    }

    public void AddLine(Guid itemId, decimal quantity, decimal unitPrice)
    {
        _lines.Add(InvoiceLine.Create(Id, itemId, quantity, unitPrice));
    }
}

Key rules:

  • Private setters — external code cannot directly mutate entity state.
  • Static factory methods (Create, Update) replace public constructors.
  • IMultiTenantEntity marks the entity for automatic tenant query filters.
  • Private EF Core constructor (private Invoice()) is always present.

FluentValidation Example

csharp
// Application/Invoice/Commands/AddInvoice/AddInvoiceCommandValidator.cs

public class AddInvoiceCommandValidator : AbstractValidator<AddInvoiceCommand>
{
    public AddInvoiceCommandValidator()
    {
        RuleFor(x => x.InvoiceNumber)
            .NotEmpty()
            .MaximumLength(50);

        RuleFor(x => x.CustomerId)
            .NotEmpty();

        RuleFor(x => x.InvoiceDate)
            .NotEmpty()
            .LessThanOrEqualTo(DateOnly.FromDateTime(DateTime.UtcNow));

        RuleFor(x => x.Lines)
            .NotEmpty()
            .WithMessage("An invoice must have at least one line.");
    }
}

Auto-Registration

Validators are registered automatically via services.AddValidatorsFromAssembly(typeof(ApplicationAssemblyMarker).Assembly). No manual registration is needed.

Internal Documentation — Microtec Platform Team