The Mutation Pipeline
From HTTP request to entity change and back — every step, every validation layer, every hook.
Mutations are how entities change state in Pragmatic. This document explains the complete pipeline: how a mutation DTO flows through validation, entity loading, application, persistence, event dispatch, and cache invalidation.
Pipeline Overview
Section titled “Pipeline Overview”HTTP POST /api/v1/invoices │ ▼┌──────────────────────────────────────────────────┐│ 1. Endpoint Handler (source-generated) ││ • Model binding → Mutation object ││ • Claim binding from HttpContext.User ││ • Pre-processors (authorization, etc.) ││ • ISyncValidator on mutation (auto) │└───────────────────┬──────────────────────────────┘ │ ▼┌──────────────────────────────────────────────────┐│ 2. MutationInvoker.InvokeAsync(mutation, ct) ││ ┌────────────────────────────────────────┐ ││ │ a. Inject dependencies │ ││ │ b. L1: Sync validation (ISyncValidator)│ ││ │ c. L1: Async validation (IAsyncValid.) │ ││ │ d. Load entity or create new │ ││ │ e. Apply computed defaults (if create) │ ││ │ f. mutation.ApplyAsync(entity) │ ││ │ g. L2: Entity validation (IValidator) │ ││ │ h. Persist (repo.Add + SaveChanges) │ ││ │ i. Dispatch domain events │ ││ │ j. Invalidate cache │ ││ └────────────────────────────────────────┘ │└───────────────────┬──────────────────────────────┘ │ ▼┌──────────────────────────────────────────────────┐│ 3. Result<TEntity, IError> ││ • Success → HTTP 201 Created / 200 OK ││ • ValidationError → HTTP 400 Bad Request ││ • NotFoundError → HTTP 404 Not Found │└──────────────────────────────────────────────────┘Mutations and Actions: The Relationship
Section titled “Mutations and Actions: The Relationship”In Pragmatic, a mutation is a specialized kind of action. Both share the same pipeline concepts (filters, validation, DI), but they solve different problems:
| Concept | Purpose | Generated Invoker |
|---|---|---|
| DomainAction | Custom business logic with explicit Execute() | DomainActionInvoker<T> |
| Mutation | Entity CRUD via declarative DTO | MutationInvoker<TMutation, TEntity> |
A DomainAction is what you write when you need full control — you implement Execute() and decide what happens. A Mutation is what you write when the operation is structural — create/update/delete an entity from a DTO.
Both flow through the same endpoint pipeline:
- Both support
[Endpoint]for HTTP handler generation - Both support
[Validate]for async validation - Both support pre/post-processors
- Both produce
Result<T, IError>
The key difference: mutations have a generated invoker that handles load-apply-save automatically, while actions have an Execute() method you write yourself.
Step 1: Endpoint Handler
Section titled “Step 1: Endpoint Handler”When a mutation class inherits from Mutation<TEntity> and has [Mutation] + [Endpoint], the SG generates a Minimal API handler.
// ═══ What YOU write ═══[Mutation(Mode = MutationMode.Create)][Endpoint(HttpVerb.Post, "api/v1/invoices")][Validate][BelongsTo<BillingBoundary>]public partial class CreateInvoiceMutation{ public required Guid ReservationId { get; init; } public required Guid GuestId { get; init; } public List<CreateLineItemDto>? Lines { get; init; }}// ═══ What the SG generates (simplified) ═══app.MapPost("api/v1/invoices", async ( [FromBody] CreateInvoiceMutation mutation, [FromServices] IMutationInvoker<CreateInvoiceMutation, Invoice> invoker, HttpContext httpContext, CancellationToken ct) =>{ // 1. Pre-processors (if registered) // var preResult = await preprocessor.ProcessAsync(context, ct);
// 2. Auto sync validation (if mutation implements ISyncValidator) if (mutation is ISyncValidator syncValidator) { var validationResult = syncValidator.Validate(); if (validationResult.IsFailure) return validationResult.ToResult(); // → HTTP 400 }
// 3. Invoke the mutation pipeline var result = await invoker.InvokeAsync(mutation, ct);
// 4. Post-processors // await postprocessor.ProcessAsync(context, result, ct);
// 5. Map result to HTTP response return result.Match( success: entity => Results.Created($"/api/v1/invoices/{entity.PersistenceId}", entity), failure: error => error.ToResult() );});Body Binding
Section titled “Body Binding”For POST/PUT endpoints, the mutation object is typically the request body. The SG handles several binding scenarios:
| Scenario | Binding |
|---|---|
| Simple mutation (all body) | [FromBody] CreateInvoiceMutation mutation |
| Route + body | Route params bound separately, body for remaining |
| Mixed (route + query + body) | SG generates a wrapper DTO and splits binding |
Step 2: MutationInvoker
Section titled “Step 2: MutationInvoker”The heart of the pipeline. MutationInvoker<TMutation, TEntity> is an abstract base class with generated concrete implementations per entity.
2a. Inject Dependencies
Section titled “2a. Inject Dependencies”// Generated: override InjectDependenciesprotected override void InjectDependencies(CreateInvoiceMutation mutation){ // If the mutation declares service dependencies, inject them mutation.SomeService = _someService;}This is for mutations that need DI services (e.g., to call an external API during ApplyAsync). Most mutations don’t need this.
2b. Level 1: Sync Validation (Input)
Section titled “2b. Level 1: Sync Validation (Input)”if (mutation is ISyncValidator syncValidator){ var syncResult = syncValidator.Validate(); if (syncResult.IsFailure) return Failure(syncResult); // → ValidationError → HTTP 400}Sync validation runs attribute-based validation ([Required], [Email], [Range], etc.) on the mutation’s properties. The Validate() method is source-generated from your validation attributes.
This is fast, synchronous, and does not hit the database. It catches obvious input errors early.
2c. Level 1: Async Validation (Input)
Section titled “2c. Level 1: Async Validation (Input)”var asyncValidator = _serviceProvider.GetService<IAsyncValidator<TMutation>>();if (asyncValidator is not null){ var asyncResult = await asyncValidator.ValidateAsync(mutation, ct); if (asyncResult.IsFailure) return Failure(asyncResult); // → ValidationError → HTTP 400}Async validation runs database-aware checks: uniqueness, existence, business rule validation that requires querying the database. You write the validator class:
public class CreateInvoiceValidator : IAsyncValidator<CreateInvoiceMutation>{ private readonly IReadRepository<Reservation, Guid> _reservations;
public async Task<ValidationError> ValidateAsync( CreateInvoiceMutation mutation, CancellationToken ct) { var exists = await _reservations.ExistsAsync( Spec<Reservation>.Where(r => r.PersistenceId == mutation.ReservationId), ct);
if (!exists) return ValidationError.Create("ReservationId", "Reservation not found");
return ValidationError.None; }}This requires [Validate] on the mutation class. Without it, async validators are not resolved.
2d. Load or Create Entity
Section titled “2d. Load or Create Entity”Based on MutationMode:
| Mode | Behavior |
|---|---|
| Create | Skip loading. Create a new entity via new TEntity() + factory. |
| Update | Load by ID from repository. Return NotFoundError if missing. |
| CreateOrUpdate | Try to load. If not found, create new. |
| Delete | Load by ID. Perform soft-delete or hard-delete. |
| Restore | Load by ID bypassing all filters (including soft-delete). Reset soft-delete fields. |
For Update, the generated invoker uses the mutation’s ID property:
// Generated:protected override async Task<Invoice?> LoadEntityAsync( CreateInvoiceMutation mutation, CancellationToken ct){ return await _repository.GetByIdAsync(mutation.Id, ct);}For Restore, the invoker disables all filters to find the soft-deleted entity:
protected override async Task<Invoice?> LoadEntityAsync( RestoreInvoiceMutation mutation, CancellationToken ct){ using var _ = _filterToggle?.DisableAll(); return await _repository.Query() .IgnoreQueryFilters() // Bypass EF Core global filter too .FirstOrDefaultAsync(e => e.PersistenceId == mutation.Id, ct);}2e. Apply Computed Defaults
Section titled “2e. Apply Computed Defaults”For MutationMode.Create, the invoker applies computed defaults before the mutation:
// If entity has [ComputedDefault<Invoice, string, InvoiceNumberGenerator>]var generator = _serviceProvider.GetRequiredService<IDefaultValueGenerator<Invoice, string>>();var invoiceNumber = await generator.GenerateAsync(entity, ct);entity.SetInvoiceNumber(invoiceNumber);This runs after entity creation but before ApplyAsync — so the mutation can override computed defaults if needed.
2f. mutation.ApplyAsync(entity)
Section titled “2f. mutation.ApplyAsync(entity)”The generated ApplyAsync method applies the mutation’s properties to the entity:
// ═══ Generated for CreateInvoiceMutation ═══public async Task<Result<Invoice, IError>> ApplyAsync(Invoice entity, CancellationToken ct){ // Required properties — always applied entity.SetReservationId(ReservationId); entity.SetGuestId(GuestId);
// Optional properties — applied when non-null // (decimal? Total → only set if provided) if (Total.HasValue) entity.SetTotal(Total.Value);
// Collection strategies if (Lines is not null) { // Replace, Merge, or Append based on [CollectionStrategy] await ApplyLinesToEntity(entity, ct); }
return entity; // Success}The entity’s setter methods (SetReservationId, SetTotal) are also source-generated. They are the only way to modify entity properties — direct property assignment is not possible because setters are private set.
2g. Level 2: Entity Validation (Change-Aware)
Section titled “2g. Level 2: Entity Validation (Change-Aware)”After applying the mutation, the invoker validates the entity itself — not the input DTO:
// Detect which properties changed (for update mode)IReadOnlySet<string>? modifiedProperties = null;if (!isCreate && entity is IChangeTracking tracking) modifiedProperties = tracking.ModifiedProperties;
// Full entity validationvar entityValidator = _serviceProvider.GetService<IValidator<TEntity>>();if (entityValidator is not null){ var result = await entityValidator.ValidateAsync(entity, modifiedProperties, ct); if (result.IsFailure) return Failure(result);}// Fallback: sync validation on entityelse if (entity is ISyncValidator syncEntityValidator){ var result = syncEntityValidator.Validate(modifiedProperties); if (result.IsFailure) return Failure(result);}Why two validation levels?
| Level | Target | When | What It Catches |
|---|---|---|---|
| L1 (step 2b-2c) | Mutation DTO | Before entity load | Invalid input (missing fields, bad format, non-existent references) |
| L2 (step 2g) | Entity | After apply | Business invariants (balance < 0, invalid state transition, cross-field rules) |
L1 is fast and prevents unnecessary database work. L2 catches violations that only become apparent after the mutation is applied to the entity.
Change-aware validation — for updates, the validator receives the set of modified properties. This allows rules like “email must be unique” to run only when email actually changed, avoiding unnecessary uniqueness checks on unmodified fields.
2h. Persist
Section titled “2h. Persist”// For new entities:_repository.Add(entity);
// Apply presets (if [HasPresets] + [PresetProvider<T>])foreach (var provider in _presetProviders){ var presets = await provider.CreatePresetsAsync(entity, lifecycleContext, ct); foreach (var preset in presets) _unitOfWork.Add(preset);}
// Check batch modeif (BatchContext.Current is { } batch){ batch.AccumulateEntity(entity); // Skip SaveChanges — batch will save later}else{ await _unitOfWork.SaveChangesAsync(ct);}Batch mode — when a BatchContext is active (e.g., during bulk imports), the invoker accumulates entities instead of saving each one individually. The batch controls chunking and transaction boundaries.
UnitOfWork is keyed by boundary — [FromKeyedServices(typeof(BillingBoundary))] IUnitOfWork ensures the mutation saves to the correct DbContext. If your entity has [BelongsTo<BillingBoundary>], the generated invoker uses keyed DI to resolve the right unit of work.
2i. Dispatch Domain Events
Section titled “2i. Dispatch Domain Events”if (entity is IHasDomainEvents eventSource && eventSource.DomainEvents.Count > 0){ var dispatcher = _serviceProvider.GetService<IDomainEventDispatcher>(); if (dispatcher is not null) await dispatcher.DispatchAsync(eventSource.DomainEvents, ct);
eventSource.ClearDomainEvents();}Domain events are raised by entity methods (e.g., TransitionTo() on a state machine, or [CascadeSource] setters). They are dispatched after SaveChanges — the entity is already persisted when handlers run.
In batch mode, events are deferred to BatchContext and dispatched when the batch completes.
2j. Invalidate Cache
Section titled “2j. Invalidate Cache”if (mutation is ICacheInvalidator cacheInvalidator){ var cacheStack = _serviceProvider.GetService<ICacheStack>(); if (cacheStack is not null) await cacheInvalidator.InvalidateAsync(cacheStack, ct);}If the mutation implements ICacheInvalidator, it can evict specific cache entries after the entity is saved.
Soft Delete and Restore
Section titled “Soft Delete and Restore”These deserve special attention because they have unique behaviors.
Soft Delete
Section titled “Soft Delete”When MutationMode.Delete targets a [SoftDelete] entity:
entity.IsDeleted = true;entity.DeletedAt = timeProvider.GetUtcNow();entity.DeletedBy = currentUser?.Id;With [SoftDelete(Cascade = true)], all child entities (from [Relation.OneToMany]) are also soft-deleted. The invoker uses a snapshot + compensation pattern:
- Snapshot: Record the current
IsDeletedstate of all children - Mark deleted: Set
IsDeleted = trueon parent and all children - SaveChanges: Persist everything
- On failure: Revert to snapshot (compensation)
This ensures atomicity — either everything is soft-deleted or nothing is.
Restore
Section titled “Restore”Restore bypasses all filters to find the soft-deleted entity:
using var _ = _filterToggle?.DisableAll(); // Pragmatic filtersvar entity = await _repository.Query() .IgnoreQueryFilters() // EF Core global filter .FirstOrDefaultAsync(e => e.PersistenceId == mutation.Id, ct);
entity.IsDeleted = false;entity.DeletedAt = null;entity.DeletedBy = null;Both Pragmatic’s filter pipeline and EF Core’s global query filters are disabled — otherwise the soft-deleted entity would be invisible to the query.
The Generated Invoker
Section titled “The Generated Invoker”For each mutation, the SG generates a concrete invoker class:
// ═══ Generated: CreateInvoiceMutationInvoker.g.cs ═══internal sealed class CreateInvoiceMutationInvoker : MutationInvoker<CreateInvoiceMutation, Invoice>{ private readonly IRepository<Invoice, Guid> _repository; private readonly IUnitOfWork _unitOfWork;
public CreateInvoiceMutationInvoker( IRepository<Invoice, Guid> repository, [FromKeyedServices(typeof(BillingBoundary))] IUnitOfWork unitOfWork, IServiceProvider serviceProvider) : base(serviceProvider) { _repository = repository; _unitOfWork = unitOfWork; }
protected override MutationMode GetMode() => MutationMode.Create;
protected override Task<Invoice?> LoadEntityAsync( CreateInvoiceMutation mutation, CancellationToken ct) => Task.FromResult<Invoice?>(null); // Create mode: no loading
protected override Invoice CreateEntity() => new Invoice();
protected override void PersistNew(Invoice entity) => _repository.Add(entity);
protected override Task<int> SaveChangesAsync(CancellationToken ct) => _unitOfWork.SaveChangesAsync(ct);
// ... InjectDependencies, ApplyComputedDefaults, etc.}The DI registration is also generated:
// In BillingMutationRegistrationExtensions.g.cspublic static IServiceCollection AddBillingMutations(this IServiceCollection services){ services.AddScoped< IMutationInvoker<CreateInvoiceMutation, Invoice>, CreateInvoiceMutationInvoker>(); // ... other mutations return services;}Boundary and Transaction Scope
Section titled “Boundary and Transaction Scope”A boundary maps to a DbContext partition. Each boundary has its own:
DbContext(with only the entities in that boundary)IUnitOfWork(keyed by boundary type)- Repository instances
// Define boundarypublic class BillingBoundary;
// Assign entities[Entity<Guid>][BelongsTo<BillingBoundary>]public partial class Invoice { /* ... */ }
[Entity<Guid>][BelongsTo<BillingBoundary>]public partial class LineItem { /* ... */ }When a mutation saves, it calls _unitOfWork.SaveChangesAsync() — which saves all tracked changes within that boundary’s DbContext. This means:
- Creating an
InvoicewithLineItemssaves both in one transaction - Changes to entities in different boundaries require separate
SaveChangescalls - Cross-boundary operations are eventually consistent (not transactional)
Why Boundaries Matter
Section titled “Why Boundaries Matter”| Without Boundaries | With Boundaries |
|---|---|
| One giant DbContext with all entities | Focused DbContexts per domain |
| Slow model building (hundreds of entities) | Fast model building (10-30 entities each) |
| No isolation between domains | Clear ownership and access control |
| All entities share one connection string | Different boundaries can use different databases |
DomainAction vs Mutation: When to Use What
Section titled “DomainAction vs Mutation: When to Use What”| Scenario | Use | Why |
|---|---|---|
| Create/update/delete an entity from a DTO | Mutation | Generated load-apply-save pipeline |
| Custom business logic (calculate pricing, orchestrate services) | DomainAction | You need Execute() control |
| CRUD with extra side effects | Mutation + IEntityLifecycle<T> | Lifecycle hooks run in the pipeline |
| Complex multi-step operation | DomainAction | Full control over order of operations |
| Bulk import / migration | Mutation + BatchContext | Batch mode with chunking |
DomainAction Pipeline (for comparison)
Section titled “DomainAction Pipeline (for comparison)”DomainActionInvoker.InvokeAsync(action, ct): 1. InjectDependencies(action) 2. PrepareActionAsync(action, ct) ← Load entities, setup 3. Filters: BeforeExecuteAsync() ← ValidationFilter, custom filters 4. action.Execute(ct) ← YOUR code 5. Filters: AfterExecuteAsync() ← Post-processing 6. SaveChangesAsync(ct)The mutation pipeline (section 2) replaces steps 3-4 with a structured load-validate-apply-validate-persist flow.
Validation Architecture Summary
Section titled “Validation Architecture Summary”┌──────────────────────────────────────────────────────┐│ Endpoint Handler ││ └─ ISyncValidator.Validate() on mutation │ ← Attribute-based (fast, no DB)└───────────────────┬──────────────────────────────────┘ │┌───────────────────▼──────────────────────────────────┐│ MutationInvoker: Level 1 ││ ├─ ISyncValidator.Validate() (mutation) │ ← Same sync validation│ └─ IAsyncValidator<TMutation>.ValidateAsync() │ ← DB-aware (existence, uniqueness)└───────────────────┬──────────────────────────────────┘ │ (entity loaded and mutation applied)┌───────────────────▼──────────────────────────────────┐│ MutationInvoker: Level 2 ││ ├─ IValidator<TEntity>.ValidateAsync(entity, mods) │ ← Entity invariants│ └─ ISyncValidator.Validate(mods) on entity │ ← Attribute-based on entity└──────────────────────────────────────────────────────┘This two-level design means:
- Bad input is rejected before loading the entity (saves a DB round-trip)
- Business invariants are checked after the mutation is applied (catches domain rule violations)
- Change-aware validation avoids unnecessary checks on unmodified fields
Related Guides
Section titled “Related Guides”- Mutations — Declaring mutations, collection strategies, nested mutations
- Entity Attributes —
[SoftDelete],[Auditable],[ConcurrencyAware],[StateMachine] - Advanced Features — Temporal, hierarchy, polymorphic, lifecycle, presets
- Query Pipeline — The read-side counterpart
- Repository — Repository interface and unit of work