Appearance
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)
| Operation | Naming Pattern | Example |
|---|---|---|
| Create | Add{Entity} | AddInvoiceCommand |
| Update | Edit{Entity} | EditInvoiceCommand |
| Delete | Delete{Entity} | DeleteInvoiceCommand |
| Toggle active | Activate{Entity} | ActivateEmployeeCommand |
| Custom action | {Action}{Entity} | PostInvoiceCommand, RenewContractCommand |
Queries (read operations)
| Operation | Naming Pattern | Example |
|---|---|---|
| Get by ID | GetById{Entity} | GetByIdInvoiceQuery |
| Get all / paged | GetAll{Entity} | GetAllInvoicesQuery |
| Get for dropdown | GetDropdown{Entity} | GetDropdownCustomerQuery |
| Export | Export{Entity} | ExportInvoiceQuery |
| Custom | Get{Description} | GetPendingApprovalsQuery |
Artefacts per operation
Each command or query folder contains four artefacts:
| File | Naming | Purpose |
|---|---|---|
{Operation}Command.cs | Suffix Command | MediatR request record |
{Operation}CommandHandler.cs | Suffix CommandHandler | Handler implementation |
{Operation}CommandValidator.cs | Suffix CommandValidator | FluentValidation rules |
Dto/{Operation}Dto.cs | DTO inside subfolder | Input / 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.csICommand 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
| Method | Description |
|---|---|
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();
}