Skip to main content

Command Validators and Authorizers

On PearDrop write-side, commands pass through a pipeline before business logic runs:

  1. Authorizer (IAuthorizationHandler<TCommand>) – security gate
  2. Validator (IValidator<TCommand>) – input correctness
  3. 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 UnauthorizedAccessException for 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 via AbstractValidator<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: InvoiceId not 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.

AspectCommand SideQuery Side
Request typeMutates stateReads state
Security gateIAuthorizationHandler<TCommand>IAuthorizationHandler<TQuery> / query authorizer
Input checksIValidator<TCommand>Usually minimal/none (query shape checks in handler or dedicated validator pattern)
Business rulesAggregate methods + domain invariantsProjection/read-model constraints
Failure impactPrevents write and transactionPrevents unauthorized data access
Primary pipeline intentProtect writes + validate mutation payloadProtect reads + constrain data visibility

For read-side equivalents, see Query Authorizers.