Appearance
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
| Layer | Project Suffix | Key Contents | Dependencies |
|---|---|---|---|
| Domain | .Domain | Entities, Value Objects, Domain Events, IMultiTenantEntity, Repository interfaces | None |
| Application | .Application | CQRS Command/Query/Handlers, FluentValidation validators, DTOs, service interfaces | Domain only |
| Infrastructure | .Infrastructure | EF Core DbContext, EF migrations, AutoMapper profiles, external HTTP clients | Application + Domain |
| Presentation | .Apis | ASP.NET Core controllers, Swagger config, auth middleware, health checks | Application 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.csNaming Conventions
| Operation | Command/Query Class Name | HTTP Verb | Route |
|---|---|---|---|
| Create | Add{Entity}Command | POST | /api/v1/{entities} |
| Update | Edit{Entity}Command | PUT | /api/v1/{entities}/{id} |
| Delete | Delete{Entity}Command | DELETE | /api/v1/{entities}/{id} |
| Get by ID | GetById{Entity}Query | GET | /api/v1/{entities}/{id} |
| Get all (paged) | GetAll{Entity}Query | GET | /api/v1/{entities} |
| Dropdown list | GetDropdown{Entity}Query | GET | /api/v1/{entities}/dropdown |
| Export | Export{Entity}Query | GET | /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. IMultiTenantEntitymarks 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.