Skip to content

CQRS Patterns

All backend services in Microtec ERP implement CQRS (Command Query Responsibility Segregation) via MediatR. This document describes the naming conventions, folder structure, interfaces, validation pipeline, and repository patterns.


Naming Conventions

Commands (write operations)

OperationNaming PatternExample
CreateAdd{Entity}AddInvoiceCommand
UpdateEdit{Entity}EditInvoiceCommand
DeleteDelete{Entity}DeleteInvoiceCommand
Toggle activeActivate{Entity}ActivateEmployeeCommand
Custom action{Action}{Entity}PostInvoiceCommand, RenewContractCommand

Queries (read operations)

OperationNaming PatternExample
Get by IDGetById{Entity}GetByIdInvoiceQuery
Get all / pagedGetAll{Entity}GetAllInvoicesQuery
Get for dropdownGetDropdown{Entity}GetDropdownCustomerQuery
ExportExport{Entity}ExportInvoiceQuery
CustomGet{Description}GetPendingApprovalsQuery

Artefacts per operation

Each command or query folder contains four artefacts:

FileNamingPurpose
{Operation}Command.csSuffix CommandMediatR request record
{Operation}CommandHandler.csSuffix CommandHandlerHandler implementation
{Operation}CommandValidator.csSuffix CommandValidatorFluentValidation rules
Dto/{Operation}Dto.csDTO inside subfolderInput / output data contract

Folder Structure

Accounting/
└── Invoices/
    ├── Commands/
    │   ├── AddInvoice/
    │   │   ├── AddInvoiceCommand.cs
    │   │   ├── AddInvoiceCommandHandler.cs
    │   │   ├── AddInvoiceCommandValidator.cs
    │   │   └── Dto/
    │   │       └── AddInvoiceDto.cs
    │   ├── EditInvoice/
    │   │   ├── EditInvoiceCommand.cs
    │   │   ├── EditInvoiceCommandHandler.cs
    │   │   ├── EditInvoiceCommandValidator.cs
    │   │   └── Dto/
    │   │       └── EditInvoiceDto.cs
    │   └── DeleteInvoice/
    │       ├── DeleteInvoiceCommand.cs
    │       ├── DeleteInvoiceCommandHandler.cs
    │       └── DeleteInvoiceCommandValidator.cs
    └── Queries/
        ├── GetByIdInvoice/
        │   ├── GetByIdInvoiceQuery.cs
        │   ├── GetByIdInvoiceQueryHandler.cs
        │   └── Dto/
        │       └── GetByIdInvoiceDto.cs
        ├── GetAllInvoices/
        │   ├── GetAllInvoicesQuery.cs
        │   ├── GetAllInvoicesQueryHandler.cs
        │   └── Dto/
        │       └── GetAllInvoicesDto.cs
        └── GetDropdownInvoice/
            ├── GetDropdownInvoiceQuery.cs
            └── GetDropdownInvoiceQueryHandler.cs

ICommand and IQuery Interfaces

Commands and queries implement typed MediatR interfaces:

csharp
// Base interfaces from Microtec.Domain
public interface ICommand<TResult> : IRequest<ApiResponse<TResult>> { }
public interface IQuery<TResult> : IRequest<ApiResponse<TResult>> { }

// Command with a scalar return
public record AddInvoiceCommand(AddInvoiceDto Dto) : ICommand<int>;

// Query returning a complex DTO
public record GetByIdInvoiceQuery(int Id) : IQuery<GetByIdInvoiceDto>;

// Query returning a paged list
public record GetAllInvoicesQuery(
    int Page,
    int PageSize,
    string? Search) : IQuery<PagedList<GetAllInvoicesDto>>;

Handler Registration

Handlers are registered automatically via the AddMediator() extension method in Program.cs. You do not need to register individual handlers:

csharp
// Program.cs / service registration
builder.Services.AddMediator(typeof(AssemblyMarker).Assembly);

All classes implementing IRequestHandler<,> in the target assembly are discovered and registered.


FluentValidation Pipeline

Validators are executed automatically via ValidationBehaviour<TRequest, TResponse>, a MediatR pipeline behaviour. The pipeline is registered once in shared startup:

csharp
// Registered automatically via Microtec.Web.Core
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));

When FluentValidation fails, the pipeline short-circuits and returns:

json
{
  "success": false,
  "messageCode": 4001,
  "validationErrors": [
    { "field": "CustomerId", "message": "'Customer Id' must be greater than 0." }
  ]
}

You never need to call .Validate() manually in a handler.


Full Example: AddInvoiceCommand

1. Command record

csharp
// Commands/AddInvoice/AddInvoiceCommand.cs
namespace Microtec.AppsPortal.Application.Invoices.Commands.AddInvoice;

public record AddInvoiceCommand(AddInvoiceDto Dto) : ICommand<int>;

2. Input DTO

csharp
// Commands/AddInvoice/Dto/AddInvoiceDto.cs
namespace Microtec.AppsPortal.Application.Invoices.Commands.AddInvoice.Dto;

public record AddInvoiceDto(
    int CustomerId,
    DateOnly InvoiceDate,
    List<AddInvoiceLineDto> Lines);

public record AddInvoiceLineDto(int ItemId, decimal Quantity, decimal UnitPrice);

3. Validator

csharp
// Commands/AddInvoice/AddInvoiceCommandValidator.cs
namespace Microtec.AppsPortal.Application.Invoices.Commands.AddInvoice;

public class AddInvoiceCommandValidator : AbstractValidator<AddInvoiceCommand>
{
    public AddInvoiceCommandValidator()
    {
        RuleFor(x => x.Dto.CustomerId).GreaterThan(0);
        RuleFor(x => x.Dto.InvoiceDate).NotEmpty();
        RuleFor(x => x.Dto.Lines)
            .NotEmpty()
            .WithMessage("Invoice must have at least one line.");

        RuleForEach(x => x.Dto.Lines).ChildRules(line =>
        {
            line.RuleFor(l => l.ItemId).GreaterThan(0);
            line.RuleFor(l => l.Quantity).GreaterThan(0);
            line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0);
        });
    }
}

4. Handler

csharp
// Commands/AddInvoice/AddInvoiceCommandHandler.cs
namespace Microtec.AppsPortal.Application.Invoices.Commands.AddInvoice;

public class AddInvoiceCommandHandler(
    IUnitOfWork<AppDbContext> unitOfWork,
    IMapper mapper,
    IClockService clock) : IRequestHandler<AddInvoiceCommand, ApiResponse<int>>
{
    public async Task<ApiResponse<int>> Handle(
        AddInvoiceCommand request,
        CancellationToken cancellationToken)
    {
        var invoice = Invoice.Create(request.Dto.CustomerId, request.Dto.InvoiceDate, clock.GetNow());
        AddLines(invoice, request.Dto.Lines);

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

        return ApiResponse.Success(invoice.Id);
    }

    private static void AddLines(Invoice invoice, List<AddInvoiceLineDto> lines)
    {
        foreach (var line in lines)
            invoice.AddLine(line.ItemId, line.Quantity, line.UnitPrice);
    }
}

Repository Pattern

IUnitOfWork<TContext> — Tenant operations

Used for all tenant-database operations inside ERP microservices:

csharp
// Injected via primary constructor
public class InvoiceHandler(IUnitOfWork<AppDbContext> unitOfWork)
{
    public async Task DoWork()
    {
        var repo = unitOfWork.Repository<Invoice>();

        // Read-only query — always AsNoTracking
        var invoices = await repo.GetQueryable()
            .AsNoTracking()
            .Where(x => x.Status == InvoiceStatus.Draft)
            .ToListAsync();

        // Mutation
        var invoice = await repo.GetByIdAsync(id);
        invoice.Post();
        await unitOfWork.SaveChangesAsync();
    }
}

IAdminUnitOfWork — Admin/platform operations

Used for the BusinessOwners admin database (tenant metadata, subscriptions, licensing):

csharp
public class TenantHandler(IAdminUnitOfWork adminUnitOfWork)
{
    public async Task DoWork()
    {
        var tenantRepo = adminUnitOfWork.Repository<Tenant>();
        var tenant = await tenantRepo.GetByIdAsync(tenantId);
    }
}

Repository method reference

MethodDescription
GetByIdAsync(id)Fetch by primary key (returns null if not found)
GetQueryable()Returns IQueryable<T> for complex queries
AddAsync(entity)Stage new entity for insert
Update(entity)Stage entity for update
Remove(entity)Stage entity for delete
SaveChangesAsync()Commit all staged changes

Controller Wiring

Controllers dispatch commands and queries to MediatR. Route names use nameof():

csharp
[ApiController]
[Route("[controller]")]
public class InvoicesController(IMediator mediator) : ControllerBase
{
    [HttpGet]
    [ProducesResponseType(typeof(ApiResponse<PagedList<GetAllInvoicesDto>>), 200)]
    public Task<IActionResult> GetAll([FromQuery] GetAllInvoicesQuery query)
        => mediator.Send(query).ToActionResult();

    [HttpGet("{Id:int}")]
    [ProducesResponseType(typeof(ApiResponse<GetByIdInvoiceDto>), 200)]
    public Task<IActionResult> GetById([FromRoute] GetByIdInvoiceQuery query)
        => mediator.Send(query).ToActionResult();

    [HttpPost]
    [ProducesResponseType(typeof(ApiResponse<int>), 200)]
    public Task<IActionResult> Add([FromBody] AddInvoiceDto dto)
        => mediator.Send(new AddInvoiceCommand(dto)).ToActionResult();

    [HttpPut]
    [ProducesResponseType(typeof(ApiResponse), 200)]
    public Task<IActionResult> Edit([FromBody] EditInvoiceDto dto)
        => mediator.Send(new EditInvoiceCommand(dto)).ToActionResult();

    [HttpDelete("{Id:int}")]
    [ProducesResponseType(typeof(ApiResponse), 200)]
    public Task<IActionResult> Delete([FromRoute] DeleteInvoiceCommand command)
        => mediator.Send(command).ToActionResult();

    [HttpGet("DropDown")]
    [ProducesResponseType(typeof(ApiResponse<List<DropdownDto>>), 200)]
    public Task<IActionResult> DropDown()
        => mediator.Send(new GetDropdownInvoiceQuery()).ToActionResult();
}

Internal Documentation — Microtec Platform Team