Skip to content

DomainAction Integration

Guide to using Pragmatic.Actions with Pragmatic.Endpoints for CQRS patterns.

Pragmatic.Endpoints integrates seamlessly with Pragmatic.Actions, allowing you to expose DomainActions as HTTP endpoints with full pipeline support (validation, filters, observability).

PatternBase ClassDI PatternPipelineUse Case
Raw EndpointEndpoint<T>SetDependenciesNoneSimple CRUD, direct logic
DomainActionDomainAction<T>DomainActionInvoker<T>FullCQRS, complex business logic

1. Define the Action with Endpoint Attribute

Section titled “1. Define the Action with Endpoint Attribute”
using Pragmatic.Actions;
using Pragmatic.Endpoints;
using Pragmatic.Result;
[Endpoint(HttpVerb.Post, "/orders")]
[EndpointSummary("Place Order")]
[Tags("Orders")]
[HttpStatus(201)]
public partial class PlaceOrder : DomainAction<OrderId, ValidationError, ConflictError>
{
// Dependencies - injected by DomainActionInvoker
private IOrderRepository Orders { get; set; } = null!;
private ICustomerRepository Customers { get; set; } = null!;
// Route parameters
// (none in this example, but would use [FromRoute])
// Header parameters
[FromHeader(Name = "X-Idempotency-Key")]
public required string IdempotencyKey { get; init; }
// Body properties (no attribute = body)
public required Guid CustomerId { get; init; }
public required List<OrderItem> Items { get; init; }
public string? Notes { get; init; }
public override async Task<Result<OrderId, IError>> Execute(CancellationToken ct)
{
// Check idempotency
if (await Orders.ExistsAsync(IdempotencyKey, ct))
return new ConflictError("Order with this idempotency key exists");
// Validate customer
var customer = await Customers.GetByIdAsync(CustomerId, ct);
if (customer is null)
return new NotFoundError("Customer", CustomerId);
// Create order
var order = new Order(customer, Items, Notes);
await Orders.CreateAsync(order, ct);
return order.Id;
}
}

The generator creates a handler using DomainActionInvoker<T>:

PlaceOrder.Handler.g.cs
internal static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder endpoints)
{
var builder = endpoints.MapPost("/orders", async (
[FromHeader(Name = "X-Idempotency-Key")] string idempotencyKey,
[FromBody] PlaceOrderBody body,
IDomainActionInvoker<PlaceOrder, OrderId> invoker,
HttpContext httpContext,
CancellationToken ct
) =>
{
var action = new PlaceOrder
{
IdempotencyKey = idempotencyKey,
CustomerId = body.CustomerId,
Items = body.Items,
Notes = body.Notes
};
var result = await invoker.InvokeAsync(action, ct);
return result.Match(
(orderId) => Results.Created($"/orders/{orderId}", orderId),
(ValidationError error) => MapError(error),
(ConflictError error) => MapError(error)
);
});
// OpenAPI metadata
builder.WithSummary("Place Order");
builder.WithTags("Orders");
builder.Produces<OrderId>(201);
builder.ProducesProblem(422); // ValidationError
builder.ProducesProblem(409); // ConflictError
return builder;
}

For operations without a return value:

[Endpoint(HttpVerb.Delete, "/users/{id}")]
[Tags("Users")]
public partial class DeleteUser : VoidDomainAction<NotFoundError, ForbiddenError>
{
private IUserRepository Users { get; set; } = null!;
[FromRoute]
public Guid Id { get; init; }
[FromQuery]
public bool? SoftDelete { get; init; }
public override async Task<VoidResult<IError>> Execute(CancellationToken ct)
{
var user = await Users.GetByIdAsync(Id, ct);
if (user is null)
return new NotFoundError("User", Id);
if (!user.CanBeDeleted)
return new ForbiddenError("Cannot delete this user");
if (SoftDelete == true)
await Users.SoftDeleteAsync(Id, ct);
else
await Users.DeleteAsync(Id, ct);
return Success;
}
}

Generated handler uses IVoidDomainActionInvoker<T>:

var result = await invoker.InvokeAsync(action, ct);
return result.Match(
() => Results.NoContent(),
(error) => MapError(error)
);

DomainAction endpoints get full pipeline support:

[Endpoint(HttpVerb.Post, "/users")]
public partial class CreateUser : DomainAction<UserId, ValidationError>
{
[Required]
[Email]
public required string Email { get; init; }
[Required]
[MinLength(2)]
public required string Name { get; init; }
// Validation runs BEFORE Execute via ValidationFilter
public override async Task<Result<UserId, IError>> Execute(CancellationToken ct)
{
// If we get here, validation passed
var user = new User(Email, Name);
await Users.CreateAsync(user, ct);
return user.Id;
}
}

All registered IActionFilter instances apply:

// Registration
services.AddActionFilter<LoggingFilter>();
services.AddActionFilter<AuditFilter>();
services.AddActionFilter<TransactionFilter>();
// Filter execution order:
// 1. BeforeExecute (ValidationFilter → AuditFilter → TransactionFilter)
// 2. Execute
// 3. AfterExecute (TransactionFilter → AuditFilter → ValidationFilter)

Automatic tracing and logging:

Activity: "Action.PlaceOrder"
Tags:
action.type: "PlaceOrder"
action.result: "Success" | "Failure"
action.error: "ValidationError" (if failed)

DomainAction endpoints are included in boundary interfaces:

// Define boundary
namespace MyApp.Orders;
[Boundary]
public partial class OrdersBoundary { }
// Actions in namespace
[Endpoint(HttpVerb.Post, "/orders")]
public partial class PlaceOrder : DomainAction<OrderId> { }
[Endpoint(HttpVerb.Delete, "/orders/{id}")]
public partial class CancelOrder : VoidDomainAction<NotFoundError> { }
// Generated interface
public partial interface IOrdersActions
{
Task<Result<OrderId>> PlaceOrder(...);
Task<VoidResult<NotFoundError>> CancelOrder(Guid id, ...);
}
Program.cs
var builder = WebApplication.CreateBuilder(args);
// Register DomainAction infrastructure
builder.Services.AddPragmaticActions();
// Register endpoints (includes invokers for DomainAction endpoints)
builder.Services.AddPragmaticEndpoints();
// Register action-specific services
builder.Services.AddOrdersActions();
var app = builder.Build();
// Map all endpoints
app.MapPragmaticEndpoints();

Combine raw endpoints and DomainAction endpoints:

// Simple query - raw endpoint
[Endpoint(HttpVerb.Get, "/orders/{id}")]
public partial class GetOrderEndpoint : Endpoint<OrderDto>
{
private IOrderRepository _orders = null!;
[FromRoute]
public Guid Id { get; set; }
public override async Task<Result<OrderDto>> HandleAsync(CancellationToken ct)
{
var order = await _orders.GetByIdAsync(Id, ct);
return order is not null
? new OrderDto(order)
: new NotFoundError("Order", Id);
}
}
// Complex command - DomainAction
[Endpoint(HttpVerb.Post, "/orders")]
public partial class PlaceOrder : DomainAction<OrderId, ValidationError>
{
// Full pipeline support for complex business logic
}

Use [DomainAction(Internal = true)] for actions not exposed as endpoints:

// Public endpoint
[Endpoint(HttpVerb.Post, "/orders")]
public partial class PlaceOrder : DomainAction<OrderId>
{
public override async Task<Result<OrderId, IError>> Execute(CancellationToken ct)
{
// Create order
var orderId = await CreateOrder();
// Call internal action (not an HTTP endpoint)
await Invoker.InvokeAsync(new RecalculateInventory
{
ProductIds = Items.Select(i => i.ProductId).ToList()
}, ct);
return orderId;
}
}
// Internal action - NOT exposed as endpoint
[DomainAction(Internal = true)]
public partial class RecalculateInventory : VoidDomainAction
{
public required List<Guid> ProductIds { get; init; }
}
  1. Use DomainActions for complex logic - Validation, multiple dependencies, side effects
  2. Use raw endpoints for simple queries - Direct database reads
  3. Always use typed errors - In DomainAction generics
  4. Include idempotency - For POST/PUT operations
  5. Leverage validation - Use attributes, let pipeline handle it
  6. Keep actions focused - Single responsibility