REST Mutations¶
Verified by tests
RestMutationEndpointBaseTests, MutationEndpointBaseTests, MutationContextTests, CommandEndpointAttributeTests — library CI run #31657041675 (2026-08-13)
Whizbang generates REST mutation endpoints for commands using FastEndpoints, providing a consistent hook architecture for validation, logging, and error handling.
Overview¶
REST mutations provide:
- Generated Endpoints - Source generators create endpoint classes from
[CommandEndpoint]attributes on command classes - Hook Architecture -
OnBefore,OnAfter,OnErrorhooks for customization - Partial Classes - Extend generated endpoints with custom logic
- Consistent Patterns - Same hooks across REST (FastEndpoints) and GraphQL (HotChocolate) transports
Defining Mutation Endpoints¶
The [CommandEndpoint<TCommand, TResult>] attribute is placed on the command class itself. The source generator discovers it and emits a <CommandName>Endpoint class in a .Generated sub-namespace.
Basic Command Endpoint¶
Basic Command Endpoint
[CommandEndpoint<CreateOrderCommand, OrderResult>(RestRoute = "/api/orders")]
public class CreateOrderCommand : ICommand {
public required Guid CustomerId { get; init; }
}
// Generates: CreateOrderCommandEndpoint (POST /api/orders)
With Custom Request DTO¶
With Custom Request DTO
[CommandEndpoint<CreateOrderCommand, OrderResult>(
RestRoute = "/api/orders",
RequestType = typeof(CreateOrderRequest))]
public class CreateOrderCommand : ICommand { }
// You must override MapRequestToCommandAsync in your partial class
CommandEndpointAttribute Properties¶
| Property | Type | Default | Description |
|---|---|---|---|
RestRoute |
string? |
null |
REST route; if null, no REST endpoint is generated |
GraphQLMutation |
string? |
null |
GraphQL mutation field name; if null, no GraphQL mutation is generated |
RequestType |
Type? |
null |
Optional custom request DTO type |
Updated
There is no HttpMethod property on [CommandEndpoint]. All generated REST mutation endpoints are registered as POST routes (MapPost) at this commit.
RestMutationEndpointBase¶
Generated endpoints inherit from RestMutationEndpointBase<TCommand, TResult>, which provides the hook architecture:
RestMutationEndpointBase
public abstract class RestMutationEndpointBase<TCommand, TResult>
: MutationEndpointBase<TCommand, TResult>
where TCommand : ICommand {
}
Hook Architecture¶
The hook lifecycle in ExecuteAsync is:
- Check cancellation
OnBeforeExecuteAsyncDispatchCommandAsyncOnAfterExecuteAsync(on success) orOnErrorAsync(on failure)
An IMutationContext is passed to every hook. It exposes the request CancellationToken and an Items dictionary (IDictionary<string, object?>) for sharing state between hooks.
OnBeforeExecuteAsync¶
Called before command dispatch. Use for validation, authorization, or logging.
OnBeforeExecuteAsync
public partial class CreateOrderCommandEndpoint {
protected override async ValueTask OnBeforeExecuteAsync(
CreateOrderCommand command,
IMutationContext context,
CancellationToken ct) {
// Validate the command
await _validator.ValidateAndThrowAsync(command, ct);
// Log the operation
_logger.LogInformation("Creating order for customer {CustomerId}", command.CustomerId);
}
}
OnAfterExecuteAsync¶
Called after successful command dispatch. Not called if dispatch throws. Use for post-processing or notifications.
OnAfterExecuteAsync
public partial class CreateOrderCommandEndpoint {
protected override async ValueTask OnAfterExecuteAsync(
CreateOrderCommand command,
OrderResult result,
IMutationContext context,
CancellationToken ct) {
// Send confirmation email
await _emailService.SendOrderConfirmationAsync(result.OrderId, ct);
// Log success
_logger.LogInformation("Order {OrderId} created successfully", result.OrderId);
}
}
OnErrorAsync¶
Called when command dispatch throws. Return a result to suppress the exception, or return null (the default) to rethrow it.
OnErrorAsync
public partial class CreateOrderCommandEndpoint {
protected override ValueTask<OrderResult?> OnErrorAsync(
CreateOrderCommand command,
Exception ex,
IMutationContext context,
CancellationToken ct) {
_logger.LogError(ex, "Failed to create order for customer {CustomerId}", command.CustomerId);
// Return a fallback result to suppress known errors
if (ex is ValidationException) {
return ValueTask.FromResult<OrderResult?>(
new OrderResult(Guid.Empty, "ValidationFailed", 0m));
}
// Return null to rethrow unexpected exceptions
return ValueTask.FromResult<OrderResult?>(null);
}
}
Complete Example¶
Command Definition¶
Command Definition
[CommandEndpoint<CreateOrderCommand, OrderResult>(RestRoute = "/api/orders")]
public record CreateOrderCommand(
Guid CustomerId,
List<OrderLineItem> Items,
ShippingAddress ShippingAddress) : ICommand;
public record OrderResult(
Guid OrderId,
string Status,
decimal TotalAmount);
Generated Endpoint (Simplified)¶
Generated Endpoint (Simplified)
// Generated by RestMutationEndpointGenerator in the <CommandNamespace>.Generated namespace
public partial class CreateOrderCommandEndpoint
: RestMutationEndpointBase<CreateOrderCommand, OrderResult>,
IEndpoint {
private readonly IDispatcher _dispatcher;
public CreateOrderCommandEndpoint(IDispatcher dispatcher) {
_dispatcher = dispatcher;
}
public void Configure(IEndpointRouteBuilder routeBuilder) {
routeBuilder.MapPost("/api/orders", HandleAsync);
}
protected override async ValueTask<OrderResult> DispatchCommandAsync(
CreateOrderCommand command,
CancellationToken ct) {
return await _dispatcher.LocalInvokeAsync<CreateOrderCommand, OrderResult>(command, ct);
}
public async Task<OrderResult> HandleAsync(CreateOrderCommand command, CancellationToken ct) {
return await ExecuteAsync(command, ct);
}
}
Custom Extension¶
Custom Extension
// Your partial class for customization
// Must be declared in the same <CommandNamespace>.Generated namespace as the generated class
public partial class CreateOrderCommandEndpoint {
private readonly IValidator<CreateOrderCommand> _validator;
private readonly ILogger<CreateOrderCommandEndpoint> _logger;
// Chain to the generated constructor for additional dependencies
public CreateOrderCommandEndpoint(
IDispatcher dispatcher,
IValidator<CreateOrderCommand> validator,
ILogger<CreateOrderCommandEndpoint> logger) : this(dispatcher) {
_validator = validator;
_logger = logger;
}
protected override async ValueTask OnBeforeExecuteAsync(
CreateOrderCommand command,
IMutationContext context,
CancellationToken ct) {
await _validator.ValidateAndThrowAsync(command, ct);
}
protected override async ValueTask OnAfterExecuteAsync(
CreateOrderCommand command,
OrderResult result,
IMutationContext context,
CancellationToken ct) {
_logger.LogInformation("Order {OrderId} created with total {Total}",
result.OrderId, result.TotalAmount);
}
}
Request/Response Examples¶
Create Order¶
Request:
POST /api/orders
Content-Type: application/json
{
"customerId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"items": [
{ "productId": "abc123", "quantity": 2, "unitPrice": 29.99 }
],
"shippingAddress": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zipCode": "62701"
}
}
Response: Create Order
Update Order¶
Updates are modeled as separate commands with their own routes. All generated mutation endpoints use POST.
Request:
POST /api/orders/update
Content-Type: application/json
{
"orderId": "550e8400-e29b-41d4-a716-446655440000",
"status": "Shipped",
"trackingNumber": "1Z999AA10123456784"
}
Validation Integration¶
FluentValidation¶
FluentValidation
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand> {
public CreateOrderCommandValidator() {
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Items).NotEmpty().WithMessage("Order must contain at least one item");
RuleFor(x => x.ShippingAddress).NotNull();
}
}
In Hook¶
In Hook
protected override async ValueTask OnBeforeExecuteAsync(
CreateOrderCommand command,
IMutationContext context,
CancellationToken ct) {
var result = await _validator.ValidateAsync(command, ct);
if (!result.IsValid) {
// Throwing here propagates directly to the caller (transport error handling).
// OnErrorAsync only wraps DispatchCommandAsync - it is NOT called for
// exceptions thrown in OnBeforeExecuteAsync.
throw new ValidationException(result.Errors);
}
}
Related Documentation¶
- REST Setup - Installation and configuration
- REST Filtering - Query endpoints
- Dispatcher - Command execution
- FastEndpoints Documentation