Skip to content

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, OnError hooks 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:

  1. Check cancellation
  2. OnBeforeExecuteAsync
  3. DispatchCommandAsync
  4. OnAfterExecuteAsync (on success) or OnErrorAsync (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

{
  "orderId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "Pending",
  "totalAmount": 59.98
}

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);
    }
}