Skip to content

Custom Request DTOs

Verified by tests

MutationEndpointBaseTests, CommandEndpointAttributeTests, GraphQLMutationLifecycleTests — library CI run #31657041675 (2026-08-13)

By default, mutation endpoints use your command type directly as the request input. However, you can specify a custom request DTO type using the RequestType property on CommandEndpointAttribute. This enables scenarios where the API contract differs from your domain commands.

When to Use Custom Request DTOs

Use custom request DTOs when:

  • API versioning: Different API versions need different input shapes
  • Transport-specific formats: REST needs flat objects, GraphQL needs nested inputs
  • Validation concerns: Request DTOs have different validation than commands
  • Privacy: Request DTOs exclude internal command fields
  • Conversion: Input needs transformation (e.g., string to Guid)

Basic Usage

Step 1: Define the Request DTO

Step 1: Define the Request DTO

// Request DTO - what the API receives
public record CreateOrderRequest(
    string CustomerEmail,           // User provides email, not ID
    List<OrderItemInput> Items
);

public record OrderItemInput(
    string ProductSku,              // User provides SKU, not ID
    int Quantity
);

Step 2: Define the Command

Step 2: Define the Command

// Command - what the domain processes
public record CreateOrderCommand(
    Guid CustomerId,                // Domain uses ID
    List<OrderItem> Items
) : ICommand;

public record OrderItem(
    Guid ProductId,                 // Domain uses ID
    int Quantity,
    decimal UnitPrice
);

Step 3: Annotate the Command

The [CommandEndpoint] attribute goes on the command class — the source generators discover it and emit the transport endpoints:

Step 3: Annotate the Command

[CommandEndpoint<CreateOrderCommand, OrderResult>(
    RestRoute = "/api/orders",
    GraphQLMutation = "createOrder",
    RequestType = typeof(CreateOrderRequest))]    // Specify custom request type
public record CreateOrderCommand(
    Guid CustomerId,
    List<OrderItem> Items
) : ICommand;

From this, the generators emit two partial classes in the <command namespace>.Generated namespace:

  • CreateOrderCommandEndpoint — REST endpoint (FastEndpoints)
  • CreateOrderCommandMutation — GraphQL mutation (HotChocolate)

Step 4: Override MapRequestToCommandAsync

You must override MapRequestToCommandAsync when using RequestType. The default implementation throws NotImplementedException. The override lives in your own partial half of the generated class (declared in the same .Generated namespace). Do not restate the where TRequest : notnull constraint — override methods inherit constraints from the base declaration:

Step 4: Override MapRequestToCommandAsync

public partial class CreateOrderCommandEndpoint {
    private readonly ICustomerLookup _customers;
    private readonly IProductLookup _products;

    protected override async ValueTask<CreateOrderCommand> MapRequestToCommandAsync<TRequest>(
        TRequest request,
        CancellationToken ct) {

        // Cast to your specific request type
        var orderRequest = (CreateOrderRequest)(object)request;

        // Look up customer by email
        var customerId = await _customers.GetIdByEmailAsync(
            orderRequest.CustomerEmail,
            ct);

        if (customerId is null) {
            throw new ValidationException("Customer not found");
        }

        // Map items with product lookup
        var items = new List<OrderItem>();
        foreach (var input in orderRequest.Items) {
            var product = await _products.GetBySkuAsync(input.ProductSku, ct);

            if (product is null) {
                throw new ValidationException($"Product {input.ProductSku} not found");
            }

            items.Add(new OrderItem(
                ProductId: product.Id,
                Quantity: input.Quantity,
                UnitPrice: product.Price
            ));
        }

        return new CreateOrderCommand(
            CustomerId: customerId.Value,
            Items: items
        );
    }
}

The MapRequestToCommandAsync Method

Signature: The MapRequestToCommandAsync Method

protected virtual ValueTask<TCommand> MapRequestToCommandAsync<TRequest>(
    TRequest request,
    CancellationToken ct) where TRequest : notnull

Parameters: - request - The incoming request DTO - ct - The cancellation token

Returns: The command to be dispatched

Default Behavior: Throws NotImplementedException with a helpful message:

When using a custom RequestType, you must override MapRequestToCommandAsync
in your partial class to map CreateOrderRequest to CreateOrderCommand.

Execution Flow

When you specify RequestType, the generated endpoint calls ExecuteWithRequestAsync instead of ExecuteAsync:

Request arrives (CreateOrderRequest)
  |
  +-> ExecuteWithRequestAsync(request, ct)
        |
        +-> MapRequestToCommandAsync(request, ct)  <-- Your override
        |       |
        |       +-> Returns CreateOrderCommand
        |
        +-> ExecuteAsync(command, ct)
              |
              +-> OnBeforeExecuteAsync(...)
              +-> DispatchCommandAsync(...)
              +-> OnAfterExecuteAsync(...) / OnErrorAsync(...)

Patterns

Pattern 1: Simple Field Mapping

When fields just need renaming or type conversion:

Pattern 1: Simple Field Mapping

// Request
public record UpdateProductRequest(
    string Id,              // String in API
    string Name,
    string PriceString      // String for decimal
);

// Command
public record UpdateProductCommand(
    Guid Id,                // Guid in domain
    string Name,
    decimal Price
) : ICommand;

// Mapping
protected override ValueTask<UpdateProductCommand> MapRequestToCommandAsync<TRequest>(
    TRequest request,
    CancellationToken ct) {

    var req = (UpdateProductRequest)(object)request;

    return ValueTask.FromResult(new UpdateProductCommand(
        Id: Guid.Parse(req.Id),
        Name: req.Name,
        Price: decimal.Parse(req.PriceString)
    ));
}

Pattern 2: Enrichment from Services

When the command needs data from services:

Pattern 2: Enrichment from Services

public partial class CreateOrderCommandEndpoint {
    private readonly ICurrentUser _user;
    private readonly IClock _clock;

    protected override ValueTask<CreateOrderCommand> MapRequestToCommandAsync<TRequest>(
        TRequest request,
        CancellationToken ct) {

        var req = (CreateOrderRequest)(object)request;

        return ValueTask.FromResult(new CreateOrderCommand(
            OrderId: Guid.CreateVersion7(),
            CustomerId: _user.Id,           // From current user context
            Items: req.Items,
            CreatedAt: _clock.UtcNow        // From clock service
        ));
    }
}

Pattern 3: Async Lookups

When mapping requires database queries:

Pattern 3: Async Lookups

public partial class AssignTaskCommandEndpoint {
    private readonly IUserLookup _users;
    private readonly IProjectLookup _projects;

    protected override async ValueTask<AssignTaskCommand> MapRequestToCommandAsync<TRequest>(
        TRequest request,
        CancellationToken ct) {

        var req = (AssignTaskRequest)(object)request;

        // Parallel lookups for efficiency
        var userTask = _users.GetByEmailAsync(req.AssigneeEmail, ct);
        var projectTask = _projects.GetByCodeAsync(req.ProjectCode, ct);

        await Task.WhenAll(userTask, projectTask);

        var user = await userTask
            ?? throw new ValidationException($"User {req.AssigneeEmail} not found");
        var project = await projectTask
            ?? throw new ValidationException($"Project {req.ProjectCode} not found");

        return new AssignTaskCommand(
            TaskId: Guid.CreateVersion7(),
            ProjectId: project.Id,
            AssigneeId: user.Id,
            Title: req.Title,
            Description: req.Description
        );
    }
}

Pattern 4: Conditional Mapping

When mapping logic varies based on request content:

Pattern 4: Conditional Mapping

public partial class ProcessPaymentCommandEndpoint {
    private readonly IPaymentGatewayResolver _gateways;

    protected override async ValueTask<ProcessPaymentCommand> MapRequestToCommandAsync<TRequest>(
        TRequest request,
        CancellationToken ct) {

        var req = (PaymentRequest)(object)request;

        // Select gateway based on payment method
        var gateway = req.PaymentMethod switch {
            "credit_card" => await _gateways.GetStripeGatewayAsync(ct),
            "bank_transfer" => await _gateways.GetPlaidGatewayAsync(ct),
            "crypto" => await _gateways.GetCoinbaseGatewayAsync(ct),
            _ => throw new ValidationException($"Unknown payment method: {req.PaymentMethod}")
        };

        return new ProcessPaymentCommand(
            PaymentId: Guid.CreateVersion7(),
            Amount: req.Amount,
            Currency: req.Currency,
            GatewayId: gateway.Id,
            GatewayConfig: gateway.Config
        );
    }
}

Validation in Mapping

You can perform validation during mapping:

Validation in Mapping

protected override async ValueTask<CreateOrderCommand> MapRequestToCommandAsync<TRequest>(
    TRequest request,
    CancellationToken ct) {

    var req = (CreateOrderRequest)(object)request;

    // Validate request
    if (string.IsNullOrWhiteSpace(req.CustomerEmail)) {
        throw new ValidationException("Customer email is required");
    }

    if (!req.Items.Any()) {
        throw new ValidationException("At least one item is required");
    }

    // Validate each item
    foreach (var item in req.Items) {
        if (item.Quantity <= 0) {
            throw new ValidationException($"Invalid quantity for {item.ProductSku}");
        }
    }

    // Continue with mapping...
    return new CreateOrderCommand(/* ... */);
}

Note: For complex validation, consider using FluentValidation in OnBeforeExecuteAsync instead. The mapping method is best for transformation logic.

Error Handling

Exceptions thrown from MapRequestToCommandAsync are not caught by OnErrorAsync. They propagate directly to the transport layer. This is intentional:

  • Mapping errors are typically validation errors (4xx)
  • Command execution errors are typically business errors (handled by OnErrorAsync)

Error Handling

// Mapping errors - return 400 Bad Request
protected override ValueTask<CreateOrderCommand> MapRequestToCommandAsync<TRequest>(...) {
    // This exception becomes HTTP 400
    throw new ValidationException("Invalid input");
}

// Execution errors - handled by OnErrorAsync
protected override ValueTask<OrderResult?> OnErrorAsync(...) {
    // Business logic errors handled here
}

Complete Example

Complete Example

// Request DTO
public record TransferFundsRequest(
    string FromAccountNumber,
    string ToAccountNumber,
    decimal Amount,
    string Currency,
    string? Reference
);

// Command - the attribute goes here; generators emit
// TransferFundsCommandEndpoint (REST) and TransferFundsCommandMutation (GraphQL)
[CommandEndpoint<TransferFundsCommand, TransferResult>(
    RestRoute = "/api/transfers",
    GraphQLMutation = "transferFunds",
    RequestType = typeof(TransferFundsRequest))]
public record TransferFundsCommand(
    Guid TransferId,
    Guid FromAccountId,
    Guid ToAccountId,
    Money Amount,
    string? Reference,
    Guid InitiatedBy
) : ICommand;

// Your partial half of the generated REST endpoint
public partial class TransferFundsCommandEndpoint {
    private readonly IAccountLookup _accounts;
    private readonly ICurrentUser _user;
    private readonly ICurrencyService _currencies;

    protected override async ValueTask<TransferFundsCommand> MapRequestToCommandAsync<TRequest>(
        TRequest request,
        CancellationToken ct) {

        var req = (TransferFundsRequest)(object)request;

        // Validate currency
        if (!_currencies.IsSupported(req.Currency)) {
            throw new ValidationException($"Currency {req.Currency} is not supported");
        }

        // Look up accounts
        var fromAccount = await _accounts.GetByNumberAsync(req.FromAccountNumber, ct)
            ?? throw new ValidationException($"Account {req.FromAccountNumber} not found");

        var toAccount = await _accounts.GetByNumberAsync(req.ToAccountNumber, ct)
            ?? throw new ValidationException($"Account {req.ToAccountNumber} not found");

        // Verify user has access to source account
        if (fromAccount.OwnerId != _user.Id) {
            throw new UnauthorizedAccessException("You don't have access to this account");
        }

        return new TransferFundsCommand(
            TransferId: Guid.CreateVersion7(),
            FromAccountId: fromAccount.Id,
            ToAccountId: toAccount.Id,
            Amount: new Money(req.Amount, req.Currency),
            Reference: req.Reference,
            InitiatedBy: _user.Id
        );
    }
}

See Also


Version 1.0.0 - Foundation Release