Command Validators and Authorizers
On PearDrop write-side, commands pass through a pipeline before business logic runs:
- Authorizer (
IAuthorizationHandler<TCommand>) – security gate - Validator (
IValidator<TCommand>) – input correctness - Handler (
ICommandHandler<TCommand>) – domain mutation
This order is important: reject unauthorized requests first, then reject invalid input, then run domain logic.
Why both exist
- Authorizer answers: Can this caller execute this command?
- Validator answers: Is this command payload structurally valid?
- Aggregate methods answer: Is this business action valid in current domain state?
Keep these concerns separate for clear diagnostics and maintainability.
Folder Structure
Typical write-side structure for a bounded context (client contracts + server execution):
YourApp.App.Client/
└── Contracts/
└── Commands/
└── CreateInvoiceCommand.cs
YourApp.App/
└── Infrastructure/
└── Domain/
└── InvoiceAggregate/
├── CommandAuthorizers/
│ └── CreateInvoiceCommandAuthorizer.cs
├── CommandValidators/
│ └── CreateInvoiceCommandValidator.cs
└── CommandHandlers/
└── CreateInvoiceCommandHandler.cs
The command type is defined in the client contracts project and referenced by server-side authorizers, validators, and handlers.
Command Authorizer Pattern
Use IAuthorizationHandler<TCommand> and throw UnauthorizedAccessException when authorization fails.
using MediatR.Behaviors.Authorization;
using PearDrop.Extensions;
using YourApp.App.Client.Contracts.Commands;
namespace YourApp.Infrastructure.Domain.InvoiceAggregate.CommandAuthorizers;
public sealed class CreateInvoiceCommandAuthorizer : IAuthorizationHandler<CreateInvoiceCommand>
{
private readonly IHttpContextAccessor httpContextAccessor;
private readonly ILogger<CreateInvoiceCommandAuthorizer> logger;
public CreateInvoiceCommandAuthorizer(
IHttpContextAccessor httpContextAccessor,
ILogger<CreateInvoiceCommandAuthorizer> logger)
{
this.httpContextAccessor = httpContextAccessor;
this.logger = logger;
}
public Task Handle(CreateInvoiceCommand request, CancellationToken cancellationToken)
{
var httpContext = this.httpContextAccessor.HttpContext;
if (httpContext == null)
{
throw new UnauthorizedAccessException("No HTTP context");
}
var userMaybe = httpContext.ToSystemUser();
if (userMaybe.HasNoValue)
{
this.logger.LogWarning("Unauthenticated user attempted CreateInvoiceCommand");
throw new UnauthorizedAccessException("Must be authenticated");
}
if (!httpContext.User.IsInRole("Accountant"))
{
throw new UnauthorizedAccessException("Accountant role required");
}
return Task.CompletedTask;
}
}
Authorizer Checklist
- Implements
IAuthorizationHandler<TCommand> - Uses
httpContext.ToSystemUser()for current user lookup - Throws
UnauthorizedAccessExceptionfor access denial - Logs denial context where useful
Command Validator Pattern
Use FluentValidation for payload-level checks and command shape rules.
using FluentValidation;
using YourApp.App.Client.Contracts.Commands;
namespace YourApp.Infrastructure.Domain.InvoiceAggregate.CommandValidators;
public sealed class CreateInvoiceCommandValidator : AbstractValidator<CreateInvoiceCommand>
{
public CreateInvoiceCommandValidator()
{
this.RuleFor(x => x.CustomerId)
.NotEmpty();
this.RuleFor(x => x.InvoiceNumber)
.NotEmpty()
.MaximumLength(50);
this.RuleFor(x => x.Total)
.GreaterThan(0m);
}
}
Validator Checklist
- Implements
IValidator<TCommand>(typically viaAbstractValidator<TCommand>) - Enforces required fields, format, and bounds
- Avoids aggregate/database state checks when possible
- Keeps rules deterministic and fast
Handler Responsibilities
The handler should focus on domain workflow:
- Load aggregate(s) through repository/specification
- Execute domain methods
- Convert domain failures to
CommandResult.Failed(...) - Persist with
SaveEntitiesAsync()
public sealed class CreateInvoiceCommandHandler :
AuditableCommandHandler<CreateInvoiceCommand, CreateInvoiceCommandResult, Invoice>
{
public CreateInvoiceCommandHandler(
IEnumerable<IValidator<CreateInvoiceCommand>> validators,
ILogger<CreateInvoiceCommandHandler> logger,
ICommandStore commandStore,
IRepositoryFactory<Invoice> repositoryFactory)
: base(validators, logger, commandStore, repositoryFactory)
{
}
protected override async Task<CommandResult<CreateInvoiceCommandResult>> HandleInternalWithRepository(
CreateInvoiceCommand request,
CancellationToken cancellationToken)
{
var invoice = Invoice.Create(
request.CustomerId,
request.InvoiceNumber,
request.Total);
this.Repository.Add(invoice);
var saveResult = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
if (!saveResult.IsSuccess)
{
return CommandResult<CreateInvoiceCommandResult>.Failed(saveResult.Error!);
}
return CommandResult<CreateInvoiceCommandResult>.Succeeded(
new CreateInvoiceCommandResult(invoice.Id));
}
}
The handler executes on the server, but the CreateInvoiceCommand contract comes from the client project.
Registration
In module registration, ensure authorizers and validators are included in DI/pipeline setup:
services.AddAuthorizersFromAssembly(typeof(Module).Assembly);
services.AddValidatorsFromAssembly(typeof(Module).Assembly);
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Module).Assembly);
});
What belongs where
- Authorizer: identity, role, tenant, ownership access checks
- Validator: payload shape and constraints (lengths, ranges, required)
- Aggregate: stateful business invariants and transitions
Example split for ApproveInvoiceCommand:
- Authorizer: user is in finance role
- Validator:
InvoiceIdnot empty - Aggregate: invoice is not already approved/cancelled
Failure Semantics
- Authorizer fails: request denied early via
UnauthorizedAccessException - Validator fails: validation errors returned; handler does not run
- Domain fails: handler returns
CommandResult.Failed(error)from aggregate result
Command vs Query (At a Glance)
They are parallel concepts with the same goal (secure and govern request execution), but they apply to different sides of CQRS.
| Aspect | Command Side | Query Side |
|---|---|---|
| Request type | Mutates state | Reads state |
| Security gate | IAuthorizationHandler<TCommand> | IAuthorizationHandler<TQuery> / query authorizer |
| Input checks | IValidator<TCommand> | Usually minimal/none (query shape checks in handler or dedicated validator pattern) |
| Business rules | Aggregate methods + domain invariants | Projection/read-model constraints |
| Failure impact | Prevents write and transaction | Prevents unauthorized data access |
| Primary pipeline intent | Protect writes + validate mutation payload | Protect reads + constrain data visibility |
For read-side equivalents, see Query Authorizers.