Architecture and Core Concepts
This guide explains why Pragmatic.Actions exists, how its pieces fit together, and how to choose the right abstraction for each situation. Read this before diving into the individual feature guides.
The Problem
Section titled “The Problem”In a typical .NET application, business operations end up scattered across service classes. Each service grows organically: constructor parameters multiply, validation logic gets duplicated, error handling is inconsistent, and cross-cutting concerns (authorization, logging, telemetry) are wired by hand.
The service class approach: scattered and brittle
Section titled “The service class approach: scattered and brittle”public class ReservationService{ private readonly IRepository<Reservation, Guid> _reservations; private readonly IReadRepository<Property, Guid> _properties; private readonly IReadRepository<RoomType, Guid> _roomTypes; private readonly IValidator<CreateReservationRequest> _validator; private readonly IAuthorizationService _authService; private readonly ILogger<ReservationService> _logger; private readonly IUnitOfWork _unitOfWork; private readonly IClock _clock;
public ReservationService( IRepository<Reservation, Guid> reservations, IReadRepository<Property, Guid> properties, IReadRepository<RoomType, Guid> roomTypes, IValidator<CreateReservationRequest> validator, IAuthorizationService authService, ILogger<ReservationService> logger, IUnitOfWork unitOfWork, IClock clock) { _reservations = reservations; _properties = properties; _roomTypes = roomTypes; _validator = validator; _authService = authService; _logger = logger; _unitOfWork = unitOfWork; _clock = clock; }
public async Task<Result<Guid>> CreateReservationAsync( CreateReservationRequest request, ClaimsPrincipal user, CancellationToken ct) { // 1. Authorization — manual, easy to forget var authResult = await _authService.AuthorizeAsync(user, "booking.reservation.create"); if (!authResult.Succeeded) return Result<Guid>.Failure(new ForbiddenError("Insufficient permissions"));
// 2. Validation — manual, duplicated across methods var validation = await _validator.ValidateAsync(request, ct); if (!validation.IsValid) return Result<Guid>.Failure(new ValidationError(validation.Errors));
// 3. Business logic — finally _logger.LogInformation("Creating reservation for guest {GuestId}", request.GuestId);
var property = await _properties.GetByIdAsync(request.PropertyId, ct); if (property is null) return Result<Guid>.Failure(new NotFoundError("Property", request.PropertyId));
var roomType = await _roomTypes.GetByIdAsync(request.RoomTypeId, ct); if (roomType is null) return Result<Guid>.Failure(new NotFoundError("RoomType", request.RoomTypeId));
var nights = (int)(request.CheckOut - request.CheckIn).TotalDays; var totalAmount = nights * roomType.BaseRate;
var reservation = Reservation.Create( request.GuestId, request.PropertyId, request.RoomTypeId, request.CheckIn, request.CheckOut, request.NumberOfGuests, totalAmount, roomType.Currency, request.SpecialRequests);
_reservations.Add(reservation); await _unitOfWork.SaveChangesAsync(ct);
_logger.LogInformation("Reservation {Id} created in {Duration}ms", reservation.Id, /* ... */);
return reservation.Id; }
// 10 more methods, each repeating the auth + validate + log + try/catch pattern...}A single operation: 60+ lines, and most of it is plumbing. The constructor has 8 parameters and will grow with every new method. Authorization and validation are duplicated in every method body. Logging follows no consistent pattern. Telemetry is absent.
Now multiply this by 50 operations across 10 services. The plumbing-to-logic ratio makes the codebase difficult to navigate and easy to break.
Entity CRUD: even more repetition
Section titled “Entity CRUD: even more repetition”public async Task<Result<Amenity>> CreateAmenityAsync( string name, AmenityCategory category, string? iconName, CancellationToken ct){ // Validate inputs (manual or via validator) if (string.IsNullOrWhiteSpace(name)) return Result<Amenity>.Failure(new ValidationError("Name is required"));
// Create entity var amenity = new Amenity(); amenity.SetName(name); amenity.SetCategory(category); if (iconName is not null) amenity.SetIconName(iconName);
// Persist _amenities.Add(amenity); await _unitOfWork.SaveChangesAsync(ct);
return amenity;}The entire method is ceremony: parameter-by-parameter mapping to entity setters, validation, persistence. The business intent — “create an amenity with these properties” — is buried in boilerplate.
The fundamental issues:
- No structure — operations live in service methods with no enforced shape
- No pipeline — cross-cutting concerns are manually wired in every method
- No composition — calling one operation from another requires passing dependencies through
- No observability — logging and telemetry are inconsistent or absent
The Solution
Section titled “The Solution”Pragmatic.Actions inverts the model. Each business operation is a self-contained class with declared inputs, typed outputs, and explicit dependencies. The source generator produces the invoker pipeline, dependency injection wiring, and DI registration at compile time.
The same reservation creation:
[DomainAction][RequirePolicy<ReservationManagementPolicy>][Endpoint(HttpVerb.Post, "api/v1/reservations")][Validate]public partial class CreateReservationAction : DomainAction<Guid, RoomUnavailableError>{ private IRepository<Reservation, Guid> _reservations = null!; private IReadRepository<Property, Guid> _properties = null!; private IReadRepository<RoomType, Guid> _roomTypes = null!; private IClock _clock = null!;
public required CreateReservationRequest Request { get; init; }
public override async Task<Result<Guid, IError>> Execute(CancellationToken ct = default) { var property = await _properties.GetByIdAsync(Request.PropertyId, ct).ConfigureAwait(false); if (property is null) return NotFoundError.For<Property, Guid>(Request.PropertyId);
var roomType = await _roomTypes.GetByIdAsync(Request.RoomTypeId, ct).ConfigureAwait(false); if (roomType is null) return NotFoundError.For<RoomType, Guid>(Request.RoomTypeId);
if (Request.NumberOfGuests > roomType.MaxOccupancy) return new RoomUnavailableError { /* ... */ };
var nights = (int)(Request.CheckOut - Request.CheckIn).TotalDays; var totalAmount = nights * roomType.BaseRate;
var reservation = Reservation.Create( Request.GuestId, Request.PropertyId, Request.RoomTypeId, Request.CheckIn, Request.CheckOut, Request.NumberOfGuests, totalAmount, roomType.Currency, Request.SpecialRequests);
_reservations.Add(reservation); return reservation.Id; }}And the amenity creation collapses to just the intent:
[Mutation(Mode = MutationMode.Create)][RequirePermission(CatalogPermissions.Amenity.Create)][Endpoint(HttpVerb.Post, "api/v1/amenities")]public partial class CreateAmenityMutation : Mutation<Amenity>{ public required string Name { get; init; } public AmenityCategory Category { get; init; } public string? IconName { get; init; }}That is the entire class. The source generator:
- Detects private fields and generates
SetDependencies()for DI injection - Generates an
Invokernested class with the full pipeline (filters, logging, telemetry) - For mutations, generates
ApplyToEntity()that maps matching properties viaentity.SetX(this.X) - Generates a
MutationInvokerwith the lifecycle: validate, load/create, apply, entity-validate, persist, dispatch events - Registers the invoker in DI automatically
- When paired with
[Endpoint], generates a full HTTP endpoint
No reflection at runtime. No runtime code generation. The generated code is visible in your IDE under obj/, fully debuggable.
How It Works: The Pipeline
Section titled “How It Works: The Pipeline”Every action executes through a deterministic pipeline. The source generator assembles the pipeline at compile time based on the base class and attributes you choose.
DomainAction Pipeline
Section titled “DomainAction Pipeline”Caller (endpoint, boundary interface, or programmatic) | vInjectDependencies(action) SG-generated: resolves private fields from DI | vPrepareActionAsync(action) [LoadEntity<T>] pre-loads entities by ID | Returns error if entity not found vBeforeExecute filters (ascending order) 100: ValidationFilter [Validate] --> ISyncValidator / IAsyncValidator 200: PermissionAuthorizationFilter [RequirePermission], [RequireAnyPermission] 210: PolicyEvaluationFilter [RequirePolicy<T>] 250: ResourceAuthorizationFilter IResourceAuthorizer<T> ???: IActionFilter<TAction> Custom typed filters 1000: LoggingFilter Structured logging | Any filter can short-circuit with failure vaction.Execute(ct) Your business logic | vAfterExecute filters (descending order) | Cannot modify result, used for side effects vSaveChangesAsync(ct) Boundary-keyed IUnitOfWork (on success only) | vResult<TReturn, IError> Typed success or errorMutation Pipeline
Section titled “Mutation Pipeline”The mutation pipeline is more structured, with two levels of validation and automatic entity lifecycle management:
Caller (endpoint, boundary interface, or programmatic) | vInjectDependencies(mutation) SG-generated: resolves private fields from DI | vL1 Sync Validation ISyncValidator on mutation (generated from attributes) | vL1 Async Validation IAsyncValidator<TMutation> from DI | vIActionFilter<TMutation> Typed business policy filters (ascending order) | Any can short-circuit vLoad or Create entity Mode-dependent: | - Create: new TEntity() | - Update: LoadEntityAsync(mutation, ct) | - Delete: LoadEntityAsync + DeleteEntity | - Restore: LoadEntityAsync + RestoreEntity | - CreateOrUpdate: try load, fallback to create vmutation.ApplyToEntity(entity) SG auto-mapped: entity.SetX(this.X) for matching properties | vmutation.ApplyAsync(entity, ct) Developer override for custom logic (state transitions, etc.) | vL2 Entity Validation IValidator<TEntity> (change-aware, only validates modified props) | vPersist Add if new, SaveChangesAsync | vDispatch domain events IDomainEventDispatcher (if entity implements IHasDomainEvents) | vCache invalidation ICacheInvalidator (if mutation implements ICacheInvalidator) | vResult<TEntity, IError> The modified entity on success, or an errorThe pipeline steps that apply depend on the base class and attributes. A DomainAction<T> with no [Validate] and no [RequirePermission] skips validation and authorization filters. A Mutation<T> always gets the full lifecycle. The SG only generates the steps that are declared — there is no performance penalty for unused features.
Choosing the Right Base Class
Section titled “Choosing the Right Base Class”The framework provides three base classes (with void variants), each designed for a specific level of complexity. The decision tree below helps you pick the right one.
Decision Table
Section titled “Decision Table”| Question | If Yes | If No |
|---|---|---|
| Custom business logic that returns a value? | DomainAction<T> | Keep reading |
| Operation that succeeds or fails, no return value? | VoidDomainAction | Keep reading |
| Standard CRUD on a persisted entity? | Mutation<T> | Keep reading |
| Need the entity lifecycle (load/create, apply, validate entity, persist, events)? | Mutation<T> | DomainAction<T> |
| Orchestrating multiple mutations atomically? | [CompositeAction] on DomainAction<T> | Single mutation |
Base Class Reference
Section titled “Base Class Reference”| Base Class | Handler Method | Return Type | Pipeline | Use When |
|---|---|---|---|---|
DomainAction<T> | Execute | Result<T, IError> | Full filter pipeline | Queries, orchestrations, complex writes |
VoidDomainAction | Execute | VoidResult<IError> | Full filter pipeline | Fire-and-forget, side effects, state transitions |
Mutation<T> | ApplyAsync (or auto-mapped) | Result<TEntity, IError> | Full lifecycle pipeline | Entity CRUD: create, update, delete, restore |
Error Type Variants
Section titled “Error Type Variants”Each base class supports up to 6 typed error parameters:
// No explicit errors (IError is always available)public partial class GetUser : DomainAction<UserDto> { }
// One error typepublic partial class CreateReservation : DomainAction<Guid, RoomUnavailableError> { }
// Two error typespublic partial class TransferFunds : DomainAction<TransferResult, InsufficientFundsError, AccountFrozenError> { }
// Void with one error typepublic partial class MarkInvoicePaid : VoidDomainAction<NotFoundError> { }
// Mutation with one error typepublic partial class CancelReservation : Mutation<Reservation, ConflictError> { }The IProducesError<T> marker interfaces are used by the SG to generate OpenAPI error schemas when the action is paired with [Endpoint].
When NOT to Use Each
Section titled “When NOT to Use Each”Do not use Mutation<T> for read operations. Mutations are tied to the persistence unit-of-work and call SaveChangesAsync at the end. Use DomainAction<T> for queries and lookups.
Do not use DomainAction<T> for simple entity CRUD. If your operation is “take these properties, map them to an entity, validate, persist” — that is exactly what Mutation<T> automates. Writing Execute() to manually call entity.SetName(this.Name) duplicates what the SG generates for free.
Do not use VoidDomainAction when you need to return data. If the caller needs the ID of a created resource, use DomainAction<Guid>. VoidDomainAction returns 204 No Content when paired with [Endpoint].
Do not use [CompositeAction] for a single entity. Composite actions are for orchestrating multiple mutations within a single transaction. If you are modifying one entity, use a regular Mutation<T>.
Action Registration Model
Section titled “Action Registration Model”The source generator produces everything needed to invoke actions through the pipeline.
1. [DomainAction] / [Mutation] attribute — compile time
Section titled “1. [DomainAction] / [Mutation] attribute — compile time”Marks a class for source generation. The SG produces:
- A
SetDependencies()partial method that injects private fields from DI - A nested
Invokerclass that implements the pipeline - DI registration code
[DomainAction]public partial class CreateReservationAction : DomainAction<Guid> { /* ... */ }
[Mutation(Mode = MutationMode.Create)]public partial class CreateAmenityMutation : Mutation<Amenity> { /* ... */ }2. AddPragmaticActions() — core service registration
Section titled “2. AddPragmaticActions() — core service registration”Registers the pipeline infrastructure: ActionCallContext, ValidationFilter, LoggingFilter, PermissionAuthorizationFilter, PolicyEvaluationFilter, ResourceAuthorizationFilter.
3. Generated AddXxxActions() / AddXxxMutations() — invoker registration
Section titled “3. Generated AddXxxActions() / AddXxxMutations() — invoker registration”The SG generates assembly-level extension methods that register all invokers:
public static IServiceCollection AddShowcaseActions(this IServiceCollection services){ services.AddScoped<IDomainActionInvoker<CreateReservationAction, Guid>, CreateReservationAction.Invoker>(); return services;}
// Generated: _Infra.Mutations.Registration.g.cspublic static IServiceCollection AddShowcaseMutations(this IServiceCollection services){ services.AddScoped<IMutationInvoker<CreateAmenityMutation, Amenity>, CreateAmenityMutation.Invoker>(); return services;}With Pragmatic.Composition (recommended)
Section titled “With Pragmatic.Composition (recommended)”When you use Pragmatic.Composition and PragmaticApp.RunAsync(), all registration is automatic — the SG-generated host calls AddPragmaticActions(), AddXxxActions(), and AddXxxMutations():
// Program.cs -- with Composition, no manual registration neededawait PragmaticApp.RunAsync(args, app =>{ app.UseAuthentication<NoOpAuthenticationHandler>("Default");});Without Composition (standalone)
Section titled “Without Composition (standalone)”If you use Actions without Composition, you call them manually:
// Program.cs -- standalone, manual registrationbuilder.Services.AddPragmaticActions();builder.Services.AddShowcaseActions(); // generatedbuilder.Services.AddShowcaseMutations(); // generatedDependency Injection Model
Section titled “Dependency Injection Model”Pragmatic.Actions uses a field-based injection pattern. The source generator scans private fields in your action class and generates a SetDependencies() method.
Convention: fields vs. properties
Section titled “Convention: fields vs. properties”[DomainAction]public partial class CreateReservation : DomainAction<Guid>{ // Dependencies -- private fields, resolved from DI private IRepository<Reservation, Guid> _reservations = null!; private IClock _clock = null!;
// Inputs -- public properties, set by the caller public required Guid GuestId { get; init; } public required DateOnly CheckIn { get; init; }}Private fields = dependencies (resolved from the DI container via SetDependencies()).
Public properties = inputs (set by the caller before invocation).
The = null! initializer on dependency fields silences the nullable warning. The SG generates SetDependencies() to populate them before Execute() runs.
What the SG generates
Section titled “What the SG generates”For the class above, the SG produces:
public partial class CreateReservation{ internal void SetDependencies( IRepository<Reservation, Guid> reservations, IClock clock) { _reservations = reservations; _clock = clock; }}And in the generated Invoker:
public sealed class Invoker : DomainActionInvoker<CreateReservation, Guid>{ private readonly IRepository<Reservation, Guid> _reservations; private readonly IClock _clock;
public Invoker(IServiceProvider serviceProvider, IRepository<Reservation, Guid> reservations, IClock clock) : base(serviceProvider) { _reservations = reservations; _clock = clock; }
protected override void InjectDependencies(CreateReservation action) { action.SetDependencies(_reservations, _clock); }}The invoker resolves dependencies once through constructor injection (standard DI), then injects them into each action instance via SetDependencies. This avoids service locator patterns while keeping action classes clean.
Invoking actions programmatically
Section titled “Invoking actions programmatically”Inject the invoker interface to call actions from other code:
public class PaymentOrchestrator( IMutationInvoker<CreateInvoiceMutation, Invoice> createInvoice, IVoidDomainActionInvoker<MarkInvoicePaidAction> markPaid){ public async Task<Result<Invoice, IError>> CreateAndPayAsync( Guid reservationId, decimal amount, CancellationToken ct) { var mutation = new CreateInvoiceMutation { ReservationId = reservationId, TotalAmount = amount }; var result = await createInvoice.InvokeAsync(mutation, ct); if (result.IsFailure) return result;
var markAction = new MarkInvoicePaidAction { Id = result.Value.Id }; var voidResult = await markPaid.InvokeAsync(markAction, ct); // ... }}Invoker interfaces
Section titled “Invoker interfaces”| Interface | For | Return |
|---|---|---|
IDomainActionInvoker<TAction, TReturn> | DomainAction<TReturn> | Result<TReturn, IError> |
IVoidDomainActionInvoker<TAction> | VoidDomainAction | VoidResult<IError> |
IMutationInvoker<TMutation, TEntity> | Mutation<TEntity> | Result<TEntity, IError> |
Error Handling Model
Section titled “Error Handling Model”Pragmatic.Actions uses the Result pattern to make error paths explicit and type-safe. Every possible error type is declared in the class signature.
Standard Result types
Section titled “Standard Result types”// DomainAction always returns Result<TReturn, IError>public override async Task<Result<Guid, IError>> Execute(CancellationToken ct){ // Return success return reservation.Id;
// Return typed error (implicit conversion) return NotFoundError.For<Property, Guid>(Request.PropertyId);
// Return custom error return new RoomUnavailableError { PropertyId = id };}
// VoidDomainAction returns VoidResult<IError>public override async Task<VoidResult<IError>> Execute(CancellationToken ct){ // Return success (convenience property) return Success;
// Return typed error return VoidResult<IError>.Failure(NotFoundError.For<Invoice, Guid>(Id));}Error type declaration
Section titled “Error type declaration”Declare error types in the generic parameters to get compile-time type safety and OpenAPI schema generation:
// The SG knows this action can produce NotFoundError and RoomUnavailableErrorpublic partial class CreateReservation : DomainAction<Guid, RoomUnavailableError> { }
// When paired with [Endpoint], the SG generates:// - 200 OK with Guid response// - 404 Not Found (built-in for all actions)// - 422 Unprocessable Entity for RoomUnavailableErrorMutation error handling
Section titled “Mutation error handling”Mutations return Result<TEntity, IError> from ApplyAsync. The pipeline maps errors automatically:
[Mutation(Mode = MutationMode.Update)]public partial class CancelReservation : Mutation<Reservation, ConflictError>{ public override async Task<Result<Reservation, IError>> ApplyAsync( Reservation entity, CancellationToken ct) { if (_clock.UtcNow > cutoff) return new ConflictError { Reason = "Cancellation window expired" };
entity.Cancel(Reason); return entity; }}Pipeline errors
Section titled “Pipeline errors”Errors can also come from the pipeline itself:
| Source | Error Type | When |
|---|---|---|
| ValidationFilter | ValidationError | Input fails sync or async validation |
| PermissionAuthorizationFilter | ForbiddenError | User lacks required permissions |
| PolicyEvaluationFilter | ForbiddenError | Policy evaluation fails |
| ResourceAuthorizationFilter | ForbiddenError | Resource-level authorization fails |
| MutationInvoker entity load | NotFoundError | Entity not found for Update/Delete |
| L2 Entity Validation | ValidationError | Entity state is invalid after apply |
All pipeline errors short-circuit execution and return immediately to the caller.
Filter Pipeline
Section titled “Filter Pipeline”Built-in filters
Section titled “Built-in filters”Pragmatic.Actions ships with five built-in filters, all enabled by default:
| Filter | Order | Trigger | Purpose |
|---|---|---|---|
ValidationFilter | 100 | [Validate] on action | Runs ISyncValidator and IAsyncValidator<T> |
PermissionAuthorizationFilter | 200 | [RequirePermission], [RequireAnyPermission] | Checks user permissions via IPermissionChecker |
PolicyEvaluationFilter | 210 | [RequirePolicy<T>] | Evaluates ResourcePolicy against ICurrentUser |
ResourceAuthorizationFilter | 250 | IResourceAuthorizer<T> registered in DI | Resource-level authorization |
LoggingFilter | 1000 | Always active | Structured logging of action execution |
Filters execute in ascending order for BeforeExecuteAsync, and in descending order for AfterExecuteAsync.
Filter ordering gaps
Section titled “Filter ordering gaps”The order values have intentional gaps for custom filters:
100 Validation 150 -- your custom filter here --200 PermissionAuthorization210 PolicyEvaluation 225 -- your custom filter here --250 ResourceAuthorization300 Transaction (reserved)400 Caching (reserved) 500-999 -- your custom filters --1000 LoggingCustom filters
Section titled “Custom filters”Three filter interfaces serve different needs:
// Global filter: applies to ALL actionspublic class AuditFilter : IActionFilter{ public int Order => 900;
public Task<VoidResult<IError>> BeforeExecuteAsync<TAction, TReturn>( TAction action, CancellationToken ct) where TAction : DomainAction<TReturn> { // Log audit trail return Task.FromResult(VoidResult<IError>.Success()); }
public Task AfterExecuteAsync<TAction, TReturn>( TAction action, Result<TReturn, IError> result, CancellationToken ct) where TAction : DomainAction<TReturn> { return Task.CompletedTask; }}
// Typed filter: applies to a specific action typepublic class ReservationBusinessRule : IActionFilter<CreateReservationAction>{ public int Order => 250;
public Task<VoidResult<IError>> BeforeExecuteAsync( CreateReservationAction action, CancellationToken ct) { // Custom business rule check }}
// Typed filter with result accesspublic class OrderResultFilter : IActionFilter<PlaceOrder, OrderResult>{ public int Order => 900; // Has access to both action and typed result in AfterExecuteAsync}Register filters in DI:
// Globalservices.AddActionFilter<AuditFilter>();
// Action-specificservices.AddActionFilter<ReservationBusinessRule, CreateReservationAction>();
// With result typeservices.AddActionFilter<OrderResultFilter, PlaceOrder, OrderResult>();Internal call bypass
Section titled “Internal call bypass”When one action calls another within the same boundary, authorization filters are automatically skipped. The ActionCallContext.IsInternalCall flag is set by the boundary interface implementation:
// PermissionAuthorizationFilter checks:if (callContext.IsInternalCall) return VoidResult<IError>.Success();This prevents redundant permission checks when action A orchestrates action B within the same boundary.
What Gets Generated
Section titled “What Gets Generated”For each class marked with [DomainAction], the source generator produces one or more files:
DomainAction output
Section titled “DomainAction output”| Generated File | Content | Condition |
|---|---|---|
{Type}.SetDependencies.g.cs | SetDependencies() method for DI field injection | If private fields exist |
{Type}.Invoker.g.cs | Nested Invoker class implementing the pipeline | Always |
{Type}.Versioning.g.cs | ExecuteActionAsync override for versioned dispatch | If ExecuteV2, ExecuteV3, etc. exist |
_Infra.Actions.Registration.g.cs | Assembly-level AddXxxActions() method | Once per assembly |
_Metadata.Actions.g.cs | Action metadata for discovery | Once per assembly |
Mutation output
Section titled “Mutation output”| Generated File | Content | Condition |
|---|---|---|
{Type}.ApplyToEntity.g.cs | ApplyToEntity() with property-to-setter mappings | If matching properties exist |
{Type}.SetDependencies.g.cs | SetDependencies() for mutation-specific dependencies | If private fields exist |
{Type}.MutationInvoker.g.cs | Nested Invoker class with full lifecycle | Always |
_Infra.Mutations.Registration.g.cs | Assembly-level AddXxxMutations() method | Once per assembly |
Boundary output
Section titled “Boundary output”| Generated File | Content | Condition |
|---|---|---|
_Boundary.{Name}.Interface.g.cs | I{Name}Actions interface with typed invoke methods | For each [Boundary] class |
_Boundary.{Name}.Implementation.g.cs | Implementation class that resolves invokers from DI | For each [Boundary] class |
_Boundary.{Name}.Registration.g.cs | DI registration for boundary interface | For each [Boundary] class |
When combined with [Endpoint]
Section titled “When combined with [Endpoint]”When a [DomainAction] or [Mutation] also has [Endpoint], the Endpoints SG generates additional files:
| Generated File | Content |
|---|---|
{Type}.Endpoint.g.cs | HTTP handler that resolves the invoker, invokes, and maps result to HTTP |
{Type}Body.RequestBody.g.cs | Body DTO record for non-route/query properties |
All generated files live under obj/Debug/net10.0/generated/ and are fully visible in the IDE. You can set breakpoints in generated code.
Mutation Mode Reference
Section titled “Mutation Mode Reference”The MutationMode enum determines the entity lifecycle:
| Mode | Entity Source | Auto-mapping | Persist | Use Case |
|---|---|---|---|---|
Create | new TEntity() | Runs ApplyToEntity | Add + SaveChanges | New entity creation |
Update | LoadEntityAsync by ID | Runs ApplyToEntity | SaveChanges | Modify existing entity |
CreateOrUpdate | Try load, fallback to create | Runs ApplyToEntity | Add if new + SaveChanges | Upsert semantics |
Delete | LoadEntityAsync by ID | Runs ApplyAsync only | Hard/soft delete + SaveChanges | Remove entity |
Restore | LoadEntityAsync (bypasses soft-delete filter) | Runs ApplyAsync only | Reset soft-delete fields + SaveChanges | Undo soft-delete |
Mode inference
Section titled “Mode inference”If you do not set Mode explicitly, the SG infers it from the class name:
| Class Name Prefix | Inferred Mode |
|---|---|
Create* | MutationMode.Create |
Update* | MutationMode.Update |
Delete* | MutationMode.Delete |
Restore* | MutationMode.Restore |
If none matches, PRAG0410 is emitted.
Return type control
Section titled “Return type control”The ReturnType property on [Mutation] controls what the generated endpoint returns:
| ReturnType | Endpoint Response | Default For |
|---|---|---|
Id | Entity’s ID (typically Guid) | Create, Update |
LogicalKey | Entity’s logical key (string) | Business-key entities |
Entity | Full entity | Simple cases |
Boundary Model
Section titled “Boundary Model”Boundaries group related actions into strongly-typed interfaces for cross-module composition.
How boundaries work
Section titled “How boundaries work”// Define a boundary[Boundary]public partial class BookingBoundary;The SG captures all [DomainAction] and [Mutation] classes whose namespace starts with the boundary’s namespace. It generates:
IBookingActions— interface with typed invoke methodsBookingActions— implementation that resolves invokers from DI- DI registration —
services.AddScoped<IBookingActions, BookingActions>()
Boundary assignment
Section titled “Boundary assignment”Actions are assigned to boundaries by namespace prefix (default) or explicit attribute:
// Implicit: Showcase.Booking.Reservations.Mutations.* -> BookingBoundary[Mutation(Mode = MutationMode.Update)]public partial class CheckInGuestMutation : Mutation<Reservation> { }
// Explicit: override namespace-based assignment[DomainAction][BelongsTo<ShippingBoundary>]public partial class SpecialShipment : DomainAction<ShipmentId> { }Sub-boundaries (feature grouping)
Section titled “Sub-boundaries (feature grouping)”When a boundary has many operations, organize them into feature folders. The SG automatically infers sub-boundaries from the namespace and generates separate interfaces:
Showcase.Booking/├── Guests/ → IBookingGuestsActions│ ├── Guest.cs (entity at sub root)│ ├── Mutations/CreateGuestMutation.cs│ └── Queries/SearchGuestsQuery.cs├── Reservations/ → IBookingReservationsActions│ ├── Reservation.cs│ ├── Mutations/ConfirmReservationMutation.cs│ └── Actions/CreateReservationAction.cs├── Events/ (cross-feature, boundary level)│ └── Handlers/└── Infrastructure/ (authorization, services, etc.)Generated:
// Sub-interfaces (one per feature folder)public interface IBookingGuestsActions{ Task<Result<Guest, IError>> CreateGuest(CreateGuestMutation mutation, CancellationToken ct = default); Task<Result<Guest, IError>> UpdateGuest(UpdateGuestMutation mutation, CancellationToken ct = default);}
// Root interface with property composition (not inheritance)public interface IBookingActions{ IBookingGuestsActions Guests { get; } IBookingReservationsActions Reservations { get; }}Usage with ISP — inject only the sub-interface you need:
public class GuestService(IBookingGuestsActions guests){ public Task CreateGuest(...) => guests.CreateGuest(mutation, ct);}No configuration needed — the SG detects feature folders automatically from namespaces. Without feature folders, the boundary generates a flat interface (backward compatible).
Cross-boundary calls
Section titled “Cross-boundary calls”Inject the boundary interface to call actions across module boundaries:
public class InvoicePaidHandler(IBookingActions booking){ public async Task HandleAsync(InvoicePaidEvent evt, CancellationToken ct) { // Cross-boundary call -- authorization is automatically skipped (internal call) await booking.MarkPaymentReceivedAsync( new MarkPaymentReceivedMutation { Id = evt.ReservationId }, ct); }}Ecosystem Integration
Section titled “Ecosystem Integration”Pragmatic.Actions is the business logic layer. Other Pragmatic modules plug into it to provide pipeline features and expose actions as HTTP endpoints.
Endpoints
Section titled “Endpoints”When a class inherits DomainAction<T> or Mutation<T> and has [Endpoint], the SG generates a handler that resolves the invoker from DI and calls InvokeAsync. The full action pipeline runs for every HTTP request.
[DomainAction][Endpoint(HttpVerb.Post, "api/v1/reservations")]public partial class CreateReservationAction : DomainAction<Guid> { /* ... */ }Validation
Section titled “Validation”The [Validate] attribute triggers the ValidationFilter to run ISyncValidator (from validation attributes on properties) and IAsyncValidator<T> (from DI) before Execute(). For mutations, L1 validation is built into the pipeline and runs automatically.
Persistence
Section titled “Persistence”Mutation<T> integrates with the persistence unit-of-work. The SG generates code that loads entities by ID, maps input properties via generated setters, and persists via SaveChangesAsync. [BelongsTo<T>] routes to the correct boundary-keyed IUnitOfWork.
Authorization
Section titled “Authorization”Three pipeline filters enforce authorization declaratively:
[RequirePermission("booking.reservation.create")]— all listed permissions required[RequireAnyPermission("booking.admin", "booking.reservation.read")]— at least one[RequirePolicy<ReservationManagementPolicy>]— composable policy evaluation
Events
Section titled “Events”Entities implementing IHasDomainEvents get their events dispatched after SaveChangesAsync in the mutation pipeline. Event handlers can trigger cross-boundary actions via boundary interfaces.
Caching
Section titled “Caching”Mutations implementing ICacheInvalidator trigger cache invalidation after successful persistence. The SG generates the invalidation call in the pipeline.
Telemetry
Section titled “Telemetry”Every action and mutation invocation creates an Activity with OpenTelemetry-compatible tags. Counters and histograms track invocation counts, durations, failures, and validation errors.
See Also
Section titled “See Also”- Getting Started — Create your first DomainAction and Mutation
- Pipeline — Filter ordering, lifecycle hooks, custom filters
- Mutations — Entity mutations, ApplyAsync, auto-mapping
- Boundaries — Boundary interfaces, internal calls, remote
- Common Mistakes — Avoid the most frequent pitfalls
- Troubleshooting — Problem/solution guide with diagnostics reference
- Showcase: Booking actions — Real-world examples
- Showcase: Billing actions — VoidDomainAction and cross-boundary patterns