Skip to content

Pragmatic.Actions

Source-generated CQRS-style domain actions for .NET 10. Declare the operation, the generator writes the pipeline.

Every business operation needs the same ceremony: resolve dependencies, validate inputs, check authorization, execute logic, handle errors, persist changes, log results, record telemetry. Across a service class with 10+ methods, this plumbing dwarfs the actual business logic.

// Without Pragmatic: 60+ lines per operation, most of it plumbing
public class ReservationService(
IRepository<Reservation, Guid> reservations,
IReadRepository<Property, Guid> properties,
IValidator<CreateReservationRequest> validator,
IAuthorizationService auth,
ILogger<ReservationService> logger,
IUnitOfWork unitOfWork)
{
public async Task<Result<Guid>> CreateReservationAsync(
CreateReservationRequest request, ClaimsPrincipal user, CancellationToken ct)
{
var authResult = await auth.AuthorizeAsync(user, "booking.reservation.create");
if (!authResult.Succeeded) return Result<Guid>.Failure(new ForbiddenError());
var validation = await validator.ValidateAsync(request, ct);
if (!validation.IsValid) return Result<Guid>.Failure(new ValidationError(validation.Errors));
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"));
// ... 20 more lines of business logic ...
reservations.Add(reservation);
await unitOfWork.SaveChangesAsync(ct);
return reservation.Id;
}
// 10 more methods, each repeating the auth + validate + log + try/catch pattern...
}

With Pragmatic.Actions, you declare WHAT the operation does. The source generator handles HOW — pipeline, DI, telemetry, and all.

// With Pragmatic: declare the shape, the SG generates the rest
[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 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 reservation = Reservation.Create(/* ... */);
_reservations.Add(reservation);
return reservation.Id;
}
}

The SG produces the invoker pipeline, DI wiring, field injection, authorization enforcement, validation, telemetry, and persistence — all at compile time, zero reflection.

For entity CRUD, it collapses even further:

[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 SG generates property mapping, entity lifecycle, validation, persistence, and DI registration.

Terminal window
dotnet add package Pragmatic.Actions

Add the source generator as a project reference:

<ProjectReference Include="..\Pragmatic.SourceGenerator\src\Pragmatic.SourceGenerator\Pragmatic.SourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />

Use DomainAction<TReturn> when you need full control over the execution logic: reading from multiple repositories, orchestrating calls, or performing complex computations.

[DomainAction]
[BelongsTo<BookingBoundary>]
[Endpoint(HttpVerb.Post, "api/v1/reservations")]
[Validate]
public partial class CreateReservationAction : DomainAction<Guid, RoomUnavailableError>
{
// Dependencies -- injected automatically via generated SetDependencies()
private IRepository<Reservation, Guid> _reservations;
private IReadRepository<Property, Guid> _properties;
private IClock _clock;
// Input properties -- bound from HTTP body or caller
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);
if (property is null)
return NotFoundError.For<Property, Guid>(Request.PropertyId);
var reservation = Reservation.Create(
Request.GuestId, Request.PropertyId,
Request.CheckIn, Request.CheckOut,
Request.NumberOfGuests);
_reservations.Add(reservation);
return reservation.Id;
}
}

Use Mutation<TEntity> for standard entity operations. The MutationInvoker handles the full lifecycle: validate, load/create entity, apply changes, validate entity, persist, dispatch events.

[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 matching properties between mutation and entity
  • Generates ApplyToEntity() that calls entity.SetName(this.Name), etc.
  • Generates a MutationInvoker that handles the full pipeline
  • Generates DI registration
  • When paired with [Endpoint], generates an HTTP endpoint

Pragmatic.Actions provides three base classes. Choose based on your operation type.

For queries, complex writes, orchestrations, or any operation that returns a result.

[DomainAction]
public partial class GetInvoiceById : DomainAction<InvoiceDto>
{
private IReadRepository<Invoice, Guid> _invoices;
public required Guid Id { get; init; }
public override async Task<Result<InvoiceDto, IError>> Execute(CancellationToken ct)
{
var invoice = await _invoices.GetByIdAsync(Id, ct);
if (invoice is null)
return NotFoundError.For<Invoice, Guid>(Id);
return invoice.ToDto();
}
}

Supports up to 6 typed error variants:

// One error type
public partial class CreateReservation : DomainAction<Guid, RoomUnavailableError> { }
// Two error types
public partial class TransferFunds : DomainAction<TransferResult, InsufficientFundsError, AccountFrozenError> { }
// Up to six
public partial class ComplexOp : DomainAction<Result, E1, E2, E3, E4, E5, E6> { }

The IProducesError<T> marker interfaces are used by the source generator to produce OpenAPI error schemas.

For operations that either succeed or fail with an error, but produce no return value.

[DomainAction]
[RequirePermission(BillingPermissions.Invoice.Update)]
[Endpoint(HttpVerb.Post, "/api/v1/invoices/{id}/pay")]
public partial class MarkInvoicePaidAction : VoidDomainAction<NotFoundError>
{
private IRepository<Invoice, Guid> _invoices;
public required Guid Id { get; init; }
[FromClaim("sub", IsRequired = false)]
public Guid PaidByUserId { get; set; }
public override async Task<VoidResult<IError>> Execute(CancellationToken ct)
{
var invoice = await _invoices.GetByIdAsync(Id, ct);
if (invoice is null)
return NotFoundError.For<Invoice, Guid>(Id);
invoice.MarkAsPaid();
invoice.SetPaidByUserId(PaidByUserId);
return Success; // convenience property on VoidDomainAction
}
}

Mutation<TEntity> — Entity Modifications

Section titled “Mutation<TEntity> — Entity Modifications”

For standard entity CRUD operations. The MutationInvoker manages the full lifecycle automatically. See Mutations Guide for full details.

// Create -- properties auto-mapped to entity.SetX() calls
[Mutation(Mode = MutationMode.Create)]
public partial class CreateAmenityMutation : Mutation<Amenity>
{
public required string Name { get; init; }
public AmenityCategory Category { get; init; }
}
// Update -- loads entity by Id, applies changes
[Mutation(Mode = MutationMode.Update)]
public partial class UpdateAmenityMutation : Mutation<Amenity>
{
public required Guid Id { get; init; }
public string? Name { get; init; }
public AmenityCategory? Category { get; init; }
}
// Delete -- loads entity, removes or soft-deletes
[Mutation(Mode = MutationMode.Delete)]
public partial class DeleteAmenityMutation : Mutation<Amenity>
{
public required Guid Id { get; init; }
}
// Restore -- undoes soft-delete
[Mutation(Mode = MutationMode.Restore)]
public partial class RestorePropertyMutation : Mutation<Property>
{
public required Guid Id { get; init; }
}

Every action executes through a filter pipeline. The invoker orchestrates the flow.

InjectDependencies(action)
|
PrepareActionAsync(action) -- [LoadEntity] pre-loads
|
BeforeExecute filters (ascending order)
100: ValidationFilter -- sync + async validation
200: PermissionAuthorizationFilter -- [RequirePermission], [RequireAnyPermission]
210: PolicyEvaluationFilter -- [RequirePolicy<T>]
250: ResourceAuthorizationFilter -- IResourceAuthorizer<T>
1000: LoggingFilter -- structured logging
|
action.Execute(ct)
|
AfterExecute filters (descending order)
|
SaveChangesAsync(ct) -- boundary IUnitOfWork

Any BeforeExecute filter can short-circuit the pipeline by returning a failure result.

The mutation pipeline is more structured, with two levels of validation:

0. InjectDependencies(mutation) -- SG-generated SetDependencies
|
1. Permission check -- [RequirePermission] / [RequireAnyPermission]
| (opt-in via PermissionAuthorizationFilter in DI)
2. L1 Sync validation -- ISyncValidator on mutation (from attributes)
|
3. L1 Async validation -- IAsyncValidator<TMutation>
|
4. IActionFilter<TMutation> -- typed business policy filters
|
5. Load or Create entity -- mode-dependent (Create/Update/Delete/Restore)
|
6. mutation.ApplyToEntity(entity) -- SG auto-mapped property setters
|
7. mutation.ApplyAsync(entity) -- developer override for custom logic
| (auto-mapped props already applied in step 6)
8. L2 Entity validation -- IValidator<TEntity> (change-aware)
|
9. Persist -- Add if new, SaveChanges
|
10. Dispatch domain events -- IDomainEventDispatcher
|
11. Cache invalidation -- ICacheInvalidator

Note: Step 1 (permission check) only runs when PermissionAuthorizationFilter is registered in DI. Mutations without [RequirePermission] skip the check automatically.

Built-in filter order constants are defined in FilterOrder:

ConstantValueFilterPurpose
Validation100ValidationFilterSync + async input validation
Authorization200PermissionAuthorizationFilter[RequirePermission] checks
210PolicyEvaluationFilter[RequirePolicy<T>] checks
250ResourceAuthorizationFilterIResourceAuthorizer<T> checks
Transaction300(reserved)Transaction wrapping
Caching400(reserved)Cache read/write
Logging1000LoggingFilterStructured logging

Filters run in ascending order for BeforeExecute and descending order for AfterExecute.

When one action calls another within the same boundary (intra-boundary call), ActionCallContext.IsInternalCall is set to true. The PermissionAuthorizationFilter and PolicyEvaluationFilter skip their checks for internal calls. This prevents double-checking permissions when action A orchestrates action B.


The source generator scans private fields in your action class and generates a SetDependencies() method that resolves them from DI:

[DomainAction]
public partial class CreateReservation : DomainAction<Guid>
{
private IRepository<Reservation, Guid> _reservations; // resolved from DI
private IClock _clock; // resolved from DI
public required Guid GuestId { get; init; } // NOT a dependency (public property)
// ...
}

Convention: Private fields without initializers are treated as dependencies. Public properties are treated as input parameters.

The source generator registers each invoker in DI. For a DomainAction, it registers:

services.AddScoped<IDomainActionInvoker<CreateReservation, Guid>, CreateReservation.Invoker>();

For a Mutation:

services.AddScoped<IMutationInvoker<CreateAmenityMutation, Amenity>, CreateAmenityMutation.Invoker>();

Inject the invoker interface to call actions from other code:

public class PaymentOrchestrator(
IMutationInvoker<CreateDraftInvoiceMutation, Invoice> createInvoice)
{
public async Task<Result<Invoice, IError>> CreateInvoiceAsync(
Guid reservationId, decimal amount, CancellationToken ct)
{
var mutation = new CreateDraftInvoiceMutation
{
ReservationId = reservationId,
TotalAmount = amount,
// ...
};
return await createInvoice.InvokeAsync(mutation, ct);
}
}

Pragmatic.Actions integrates with Pragmatic.Authorization through three pipeline filters, each addressing a different authorization concern.

Enforces that the current user has specific permissions. All listed permissions must be present.

[RequirePermission(CatalogPermissions.Amenity.Create)]
public partial class CreateAmenityMutation : Mutation<Amenity> { }

Passes if the user has at least one of the listed permissions.

[RequireAnyPermission("booking.reservation.read", "booking.admin")]
public partial class ViewReservation : DomainAction<ReservationDto> { }

For rules more complex than a single permission check. The policy class must extend ResourcePolicy and implement Evaluate(ICurrentUser).

[RequirePolicy<ReservationManagementPolicy>]
public partial class CreateReservationAction : DomainAction<Guid> { }

When authorization depends on the specific data being accessed (e.g., “can this user edit THIS invoice?”), register an IResourceAuthorizer<T> in DI. The ResourceAuthorizationFilter resolves it automatically.

When actions call each other within the same boundary via generated boundary interfaces, they enter “internal call” mode. Authorization filters detect ActionCallContext.IsInternalCall and skip permission/policy checks, preventing redundant validation.


Boundaries group related actions into a strongly-typed interface for cross-module composition.

[Boundary]
public partial class CatalogBoundary;

The source generator captures all [DomainAction] and [Mutation] classes whose namespace starts with the boundary’s namespace (e.g., Showcase.Catalog.*). It generates an ICatalogActions interface with typed invoke methods for each action.

Actions are assigned to boundaries by namespace prefix matching (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> { }
[Boundary(Visibility = BoundaryVisibility.Internal)]
public partial class InternalServiceBoundary;

BoundaryVisibility.Internal generates only an internal interface — the boundary cannot be called from other assemblies or exposed via endpoints.

When a boundary needs to read entities from another boundary via SQL join (same physical database):

[Boundary]
[ReadAccess<Property>]
[ReadAccess<RoomType>]
public partial class BookingBoundary;

The persistence source generator adds DbSet<Property> to BookingBoundary’s DbContext with ExcludeFromMigrations().

Large boundaries can be split into sub-groups:

[SubBoundary(Name = "Reservations")]
public class ReservationActions { }

Sub-boundaries generate their own interface and implementation, composed into the root boundary as a property.

Boundaries can run in-process or be accessed via HTTP:

// Local -- same process, direct database access
services.AddBoundary<CatalogBoundary>(cfg => cfg
.UseLocal()
.UseDatabase(opt => opt.UseNpgsql(connectionString)));
// Remote -- via HTTP API
services.AddBoundary<BillingBoundary>(cfg => cfg
.UseRemote("https://billing-api.example.com"));

Call ValidateBoundaryTopology() to verify at startup:

  • No circular dependencies between boundaries
  • All required dependencies are registered
  • No duplicate boundary registrations
services.ValidateBoundaryTopology();

See Boundaries Guide for full details.


The ValidationFilter runs at Order 100 (first in the pipeline). Behavior depends on attributes:

AttributeSync ValidationAsync Validation
(none)YesNo
[Validate]YesYes
[Validate(AsyncOnly = true)]NoYes
[NoValidation]NoNo

Sync validation uses ISyncValidator (generated from validation attributes). Async validation uses IAsyncValidator<T> resolved from DI. Nested properties that implement ISyncValidator or have IAsyncValidator<T> registered are also validated automatically.


Use [SinceVersion] on properties and implement ExecuteV2(), ExecuteV3(), etc. for versioned API evolution:

[DomainAction]
[Endpoint(HttpVerb.Post, "api/v1/reservations")]
public partial class CreateReservation : DomainAction<Guid>
{
public required CreateReservationRequest Request { get; init; }
[SinceVersion("2.0")]
public string? LoyaltyMemberId { get; init; }
public override Task<Result<Guid, IError>> Execute(CancellationToken ct) { /* V1 logic */ }
public Task<Result<Guid, IError>> ExecuteV2(CancellationToken ct) { /* V2 logic */ }
}

The source generator creates separate versioned endpoints and routes to the correct Execute method.


Use [CompositeAction] to orchestrate multiple mutations within a single transaction:

[CompositeAction]
public partial class TransferGuest : DomainAction<TransferResult>
{
// SG generates a transactional invoker that calls each mutation's
// ExecuteWithoutSave and commits atomically
}

Use [LoadEntity<T>] to automatically load entities before Execute() runs:

[DomainAction]
[LoadEntity<Invoice>(nameof(InvoiceId))]
[LoadEntity<Guest>(nameof(GuestId))]
public partial class ProcessPayment : DomainAction<PaymentResult>
{
public required Guid InvoiceId { get; init; }
public required Guid GuestId { get; init; }
// _invoice and _guest are populated before Execute() via PrepareActionAsync
public override async Task<Result<PaymentResult, IError>> Execute(CancellationToken ct)
{
// _invoice and _guest are already loaded
}
}

The source generator creates the _invoice and _guest fields and overrides PrepareActionAsync to load them from their repositories, returning EntityNotFoundError if not found.


ActionsDiagnostics provides built-in observability via System.Diagnostics:

ActivitySource (Pragmatic.Actions): Every action/mutation invocation creates an Activity with tags following OpenTelemetry semantic conventions (action.name, action.kind, action.result, error.code).

Meters:

InstrumentTypeDescription
pragmatic.actions.durationHistogram (ms)Action execution duration
pragmatic.actions.invocationsCounterTotal action invocations
pragmatic.actions.failuresCounterBusiness error count
pragmatic.actions.filter_short_circuitsCounterPipeline short-circuits
pragmatic.mutations.durationHistogram (ms)Mutation execution duration
pragmatic.mutations.invocationsCounterTotal mutation invocations
pragmatic.mutations.failuresCounterMutation failure count
pragmatic.mutations.validation_failuresCounterL1 + L2 validation failures

services.AddPragmaticActions();

This registers the ActionCallContext, ValidationFilter, LoggingFilter, PermissionAuthorizationFilter, ResourceAuthorizationFilter, and PolicyEvaluationFilter.

services.AddPragmaticActions(options =>
{
options.EnableValidationFilter = true; // default: true
options.EnableLoggingFilter = true; // default: true
options.EnablePermissionFilter = true; // default: true
options.EnableResourceAuthorizationFilter = true; // default: true
options.EnablePolicyFilter = true; // default: true
});

Register global or action-specific filters:

// Global filter (applies to all actions)
services.AddActionFilter<MyAuditFilter>();
// Action-specific filter (applies to one action type)
services.AddActionFilter<MyBusinessRule, CreateReservation>();
// Typed filter with access to the result
services.AddActionFilter<MyResultFilter, CreateReservation, Guid>();

ProblemSolution
Business operations scattered across servicesDomainAction<T> encapsulates operation + dependencies + result
Manual constructor injection grows unwieldyPrivate fields auto-detected, SetDependencies() generated
No consistent error handlingResult<T, E> with up to 6 typed error variants
Validation duplicated or forgotten[Validate] triggers sync + async validators via pipeline
Cross-cutting concerns need manual wiringIActionFilter pipeline with ordered filters
Actions need HTTP endpoints[Endpoint] generates full minimal API endpoint
JWT claims needed in business logic[FromClaim("claim")] binds properties from JWT
Transaction isolation per domain[BelongsTo<TBoundary>] routes to correct IUnitOfWork
Versioned APIs require different request shapes[SinceVersion("2.0")] + ExecuteV2()
Cross-action compositionInject IDomainActionInvoker<T, TReturn> for child actions
Entity pre-loading boilerplate[LoadEntity<T>] auto-loads before Execute()
Entity CRUD boilerplateMutation<TEntity> with auto-mapped properties
State machine transitionsOverride ApplyAsync for controlled state changes
Soft-delete with compensationMutationMode.Delete + ISoftDelete, auto-rollback on failure
Restore soft-deleted entitiesMutationMode.Restore resets ISoftDelete fields

AttributeTargetPurpose
[DomainAction]ClassMarks as a domain action, enables SG
[DomainAction(Internal = true)]ClassExcludes from boundary interface
[DomainAction(System = true)]ClassInfrastructure action, no boundary
[Mutation]ClassMarks as entity mutation
[Mutation(Mode = ...)]ClassSets Create/Update/Delete/Restore mode
[Mutation(ReturnType = ...)]ClassControls response: Id, LogicalKey, Entity
[Mutation(SoftDelete = true)]ClassDelete uses soft-delete
[CompositeAction]ClassOrchestrates multiple mutations atomically
[BelongsTo<T>]ClassAssigns action to a boundary
[Boundary]ClassDefines a boundary group
[SubBoundary]ClassSub-grouping within a boundary
[ReadAccess<T>]ClassBoundary reads entity from another boundary
[Validate]ClassEnables async validation
[NoValidation]ClassDisables all validation
[LoadEntity<T>]ClassPre-loads entity before Execute()
[Include("nav")]ClassEagerly loads navigation for mutation entity
[RequirePermission]ClassRequires all listed permissions
[RequireAnyPermission]ClassRequires at least one permission
[RequirePolicy<T>]ClassRequires policy evaluation
[SinceVersion("2.0")]PropertyMarks property as added in version
[FromClaim("claim")]PropertyBinds from JWT claim

IDSeverityDescription
PRAG0400ErrorAction class must be partial
PRAG0401ErrorAction must inherit from DomainAction<T> or VoidDomainAction
PRAG0402InfoAction has no injectable dependencies (private fields)
PRAG0404Error[LoadEntity] ID property not found on the action class
PRAG0405Error[LoadEntity] could not determine entity key type
PRAG0406Error[Boundary] class must be partial
PRAG0407Error[Boundary] class must be in a namespace
PRAG0409ErrorMutation class must inherit from Mutation<TEntity>
PRAG0410ErrorMutation mode could not be determined (specify [Mutation(Mode = ...)] or use Create/Update prefix)
PRAG0412WarningSubBoundary nesting deeper than 2 levels — consider flattening
PRAG0413InfoSubBoundary inferred from namespace structure

See samples/Pragmatic.Actions.Samples/ for 5 pattern catalogs: action catalog (all SG output patterns), error handling (typed errors, implicit conversions, multi-error), mutation patterns (create, update, pipeline flow), DI wiring (registration, invoker, sub-boundary interfaces), and advanced patterns (CompositeAction, LoadEntity, Query).

| Architecture and Concepts | Why Actions exists, pipeline architecture, choosing the right base class | | 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 12 most frequent pitfalls | | Troubleshooting | Problem/solution guide with diagnostics reference |

With ModuleIntegration
Pragmatic.Endpoints[Endpoint] on action generates HTTP endpoint
Pragmatic.Validation[Validate] runs sync + async validators
Pragmatic.Persistence[BelongsTo<T>] provides boundary-keyed IUnitOfWork
Pragmatic.Authorization[RequirePermission], [RequirePolicy<T>], IResourceAuthorizer<T>
Pragmatic.Mapping[MapFrom<T>] DTOs as action input/output
Pragmatic.CachingICacheInvalidator on mutations
Pragmatic.EventsDomain events dispatched after mutation persist
Pragmatic.CompositionModule metadata for cross-assembly discovery
  • .NET 10.0+
  • Pragmatic.SourceGenerator analyzer

Part of the Pragmatic.Design ecosystem.