Skip to content

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.


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 │
└──────────────────────────────────────────────────┘

In Pragmatic, a mutation is a specialized kind of action. Both share the same pipeline concepts (filters, validation, DI), but they solve different problems:

ConceptPurposeGenerated Invoker
DomainActionCustom business logic with explicit Execute()DomainActionInvoker<T>
MutationEntity CRUD via declarative DTOMutationInvoker<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.


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()
);
});

For POST/PUT endpoints, the mutation object is typically the request body. The SG handles several binding scenarios:

ScenarioBinding
Simple mutation (all body)[FromBody] CreateInvoiceMutation mutation
Route + bodyRoute params bound separately, body for remaining
Mixed (route + query + body)SG generates a wrapper DTO and splits binding

The heart of the pipeline. MutationInvoker<TMutation, TEntity> is an abstract base class with generated concrete implementations per entity.

// Generated: override InjectDependencies
protected 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.

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.

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.

Based on MutationMode:

ModeBehavior
CreateSkip loading. Create a new entity via new TEntity() + factory.
UpdateLoad by ID from repository. Return NotFoundError if missing.
CreateOrUpdateTry to load. If not found, create new.
DeleteLoad by ID. Perform soft-delete or hard-delete.
RestoreLoad 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);
}

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.

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 validation
var 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 entity
else if (entity is ISyncValidator syncEntityValidator)
{
var result = syncEntityValidator.Validate(modifiedProperties);
if (result.IsFailure) return Failure(result);
}

Why two validation levels?

LevelTargetWhenWhat It Catches
L1 (step 2b-2c)Mutation DTOBefore entity loadInvalid input (missing fields, bad format, non-existent references)
L2 (step 2g)EntityAfter applyBusiness 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.

// 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 mode
if (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.

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.

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.


These deserve special attention because they have unique behaviors.

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:

  1. Snapshot: Record the current IsDeleted state of all children
  2. Mark deleted: Set IsDeleted = true on parent and all children
  3. SaveChanges: Persist everything
  4. On failure: Revert to snapshot (compensation)

This ensures atomicity — either everything is soft-deleted or nothing is.

Restore bypasses all filters to find the soft-deleted entity:

using var _ = _filterToggle?.DisableAll(); // Pragmatic filters
var 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.


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.cs
public static IServiceCollection AddBillingMutations(this IServiceCollection services)
{
services.AddScoped<
IMutationInvoker<CreateInvoiceMutation, Invoice>,
CreateInvoiceMutationInvoker>();
// ... other mutations
return services;
}

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 boundary
public 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 Invoice with LineItems saves both in one transaction
  • Changes to entities in different boundaries require separate SaveChanges calls
  • Cross-boundary operations are eventually consistent (not transactional)
Without BoundariesWith Boundaries
One giant DbContext with all entitiesFocused DbContexts per domain
Slow model building (hundreds of entities)Fast model building (10-30 entities each)
No isolation between domainsClear ownership and access control
All entities share one connection stringDifferent boundaries can use different databases

DomainAction vs Mutation: When to Use What

Section titled “DomainAction vs Mutation: When to Use What”
ScenarioUseWhy
Create/update/delete an entity from a DTOMutationGenerated load-apply-save pipeline
Custom business logic (calculate pricing, orchestrate services)DomainActionYou need Execute() control
CRUD with extra side effectsMutation + IEntityLifecycle<T>Lifecycle hooks run in the pipeline
Complex multi-step operationDomainActionFull control over order of operations
Bulk import / migrationMutation + BatchContextBatch mode with chunking
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.


┌──────────────────────────────────────────────────────┐
│ 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