Mutations Guide
Mutations are structured entity operations that follow a strict pipeline: validate, load/create, apply changes, validate entity, persist, dispatch events. The MutationInvoker handles all of this automatically.
When to Use Mutation vs DomainAction
Section titled “When to Use Mutation vs DomainAction”| Use Case | Choose |
|---|---|
| Standard entity CRUD (create, update, delete) | Mutation<TEntity> |
| Query / read operation | DomainAction<TReturn> |
| Complex orchestration across multiple entities | DomainAction<TReturn> |
| Operation that doesn’t modify a single entity | DomainAction<TReturn> or VoidDomainAction |
| Upsert with non-PK lookup | DomainAction<TReturn> (use CreateOrUpdate mode only for PK-based) |
Mutation Modes
Section titled “Mutation Modes”The MutationMode enum controls how the invoker handles the entity:
Create
Section titled “Create”Creates a new entity instance and persists it.
[Mutation(Mode = MutationMode.Create)]public partial class CreateAmenityMutation : Mutation<Amenity>{ public required string Name { get; init; } public AmenityCategory Category { get; init; }}Pipeline: new TEntity() -> ApplyToEntity() -> ApplyAsync() -> validate entity -> Add() -> SaveChanges() -> dispatch events.
Update
Section titled “Update”Loads an existing entity by Id and 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; }}Pipeline: load entity by Id -> reset change tracking -> ApplyToEntity() -> ApplyAsync() -> validate entity (change-aware) -> SaveChanges() -> dispatch events.
If the entity is not found, the invoker returns NotFoundError.
CreateOrUpdate
Section titled “CreateOrUpdate”Attempts to load by Id; if not found, creates a new entity instead.
[Mutation(Mode = MutationMode.CreateOrUpdate)]public partial class UpsertProductMutation : Mutation<Product>{ public Guid Id { get; init; } // non-default = update, default = create public required string Name { get; init; }}Delete
Section titled “Delete”Loads the entity, optionally runs ApplyAsync for pre-delete logic, then removes it.
[Mutation(Mode = MutationMode.Delete)]public partial class DeleteAmenityMutation : Mutation<Amenity>{ public required Guid Id { get; init; }}If the entity implements ISoftDelete, the invoker sets IsDeleted = true, DeletedAt, and DeletedBy. If SaveChanges fails, soft-delete fields are rolled back (compensation pattern).
Restore
Section titled “Restore”Restores a soft-deleted entity by resetting ISoftDelete fields. The invoker disables the soft-delete query filter to load the entity.
[Mutation(Mode = MutationMode.Restore)]public partial class RestorePropertyMutation : Mutation<Property>{ public required Guid Id { get; init; }}Mode Inference
Section titled “Mode Inference”If Mode is not set explicitly, it is inferred from the class name prefix:
Create{Entity}...->MutationMode.CreateUpdate{Entity}...->MutationMode.UpdateDelete{Entity}...->MutationMode.Delete
Auto-Mapping (ApplyToEntity)
Section titled “Auto-Mapping (ApplyToEntity)”For mutations where you don’t override ApplyAsync, the source generator creates an ApplyToEntity() override that maps matching properties from the mutation to the entity via entity.SetX() calls.
Matching rules:
- Mutation property
Namemaps to entity methodSetName() - Mutation property
Categorymaps to entity methodSetCategory() - Only properties with matching
SetX()methods on the entity are mapped - Null values in update mutations skip the setter (only non-null values are applied)
Example: Given this mutation:
[Mutation(Mode = MutationMode.Create)]public partial class CreateAmenityMutation : Mutation<Amenity>{ public required string Name { get; init; } public AmenityCategory Category { get; init; } public string? IconName { get; init; }}The SG generates:
public partial class CreateAmenityMutation{ public override void ApplyToEntity(Amenity entity) { entity.SetName(Name); entity.SetCategory(Category); entity.SetIconName(IconName); }}Auto-Mapping Always Runs
Section titled “Auto-Mapping Always Runs”ApplyToEntity() runs BEFORE ApplyAsync(). This means auto-mapped properties are always applied, even when you override ApplyAsync for custom logic. You don’t need to manually call entity.SetX() for the mapped properties.
ApplyAsync — Custom Logic
Section titled “ApplyAsync — Custom Logic”Override ApplyAsync when you need logic beyond simple property mapping: state machine transitions, creating child entities, or complex business rules.
[Mutation(Mode = MutationMode.Update)]public partial class CheckInGuestMutation : Mutation<Reservation, ConflictError>{ private ICurrentUser _currentUser; private IRepository<RoomAssignment, Guid> _roomAssignments;
public required Guid Id { get; init; } public string? RoomNumber { get; init; }
public override async Task<Result<Reservation, IError>> ApplyAsync( Reservation entity, CancellationToken ct = default) { var checkedInBy = _currentUser.IsAuthenticated ? _currentUser.Id : "system";
// State machine transition (entity domain method) var result = entity.CheckInGuest(checkedInBy); if (result.IsFailure) return Result<Reservation, IError>.Failure(result.Error!);
// Create a related entity in the same UoW if (!string.IsNullOrWhiteSpace(RoomNumber)) { var assignment = RoomAssignment.Create(entity.Id, entity.GuestId, RoomNumber); _roomAssignments.Add(assignment); }
return Result<Reservation, IError>.Success(entity); }}ApplyAsync with Auto-Mapping
Section titled “ApplyAsync with Auto-Mapping”When both auto-mapping and ApplyAsync are present, the execution order is:
ApplyToEntity(entity)— auto-mapped setters run firstApplyAsync(entity, ct)— your custom logic runs second
This means entity already has the auto-mapped properties set when ApplyAsync receives it.
Creating Child Entities in ApplyAsync
Section titled “Creating Child Entities in ApplyAsync”A common pattern is creating child entities alongside the parent:
[Mutation(Mode = MutationMode.Create)]public partial class CreateDraftInvoiceMutation : Mutation<Invoice>{ public required decimal SubTotal { get; init; } // ... other properties auto-mapped
public override Task<Result<Invoice, IError>> ApplyAsync( Invoice entity, CancellationToken ct = default) { // Auto-mapped properties (ReservationId, GuestId, etc.) are already set // Add a child entity that needs the parent's Id var lineItem = LineItem.Create(entity.Id, SubTotal, SubTotal); lineItem.SetDescription("Room charge"); entity.LineItems.Add(lineItem);
return Task.FromResult(Result<Invoice, IError>.Success(entity)); }}Mutation Pipeline — Full Sequence
Section titled “Mutation Pipeline — Full Sequence”Here is the complete pipeline executed by MutationInvoker<TMutation, TEntity>:
0. Inject Dependencies
Section titled “0. Inject Dependencies”The generated invoker calls InjectDependencies(mutation) which resolves all private fields from DI.
1. L1 Sync Validation
Section titled “1. L1 Sync Validation”If the mutation implements ISyncValidator (generated from validation attributes), Validate() is called. Failure short-circuits with ValidationError.
2. L1 Async Validation
Section titled “2. L1 Async Validation”If an IAsyncValidator<TMutation> is registered in DI, ValidateAsync() is called. Failure short-circuits.
2b. Typed Action Filters
Section titled “2b. Typed Action Filters”Any IActionFilter<TMutation> registered in DI runs (ordered by Order). These can short-circuit with business rule errors.
3. Load or Create Entity
Section titled “3. Load or Create Entity”Based on MutationMode:
- Create: calls
CreateEntity()(typicallynew TEntity()), marks as new - Update: calls
LoadEntityAsync()from repository, resets change tracking - Delete: loads entity from repository
- Restore: loads entity bypassing soft-delete filter
- CreateOrUpdate: tries load; if null, creates
If the entity is not found (Update/Delete/Restore), returns NotFoundError.
For Create mode, ApplyComputedDefaultsAsync() runs (if entity has [ComputedDefault] properties).
4a. Apply Auto-Mapping
Section titled “4a. Apply Auto-Mapping”mutation.ApplyToEntity(entity) runs — the SG-generated property mapping via entity.SetX() calls.
4b. Apply Custom Logic
Section titled “4b. Apply Custom Logic”mutation.ApplyAsync(entity, ct) runs — the developer’s override (or no-op default).
5. L2 Entity Validation
Section titled “5. L2 Entity Validation”After changes are applied, entity validation runs:
- First tries
IValidator<TEntity>from DI (unified sync + async, change-aware) - Falls back to
ISyncValidatoron the entity itself
Change-awareness: For updates, IChangeTracking.ModifiedProperties is passed to the validator, allowing it to validate only modified fields.
6. Persist
Section titled “6. Persist”- Create:
PersistNew(entity)adds to repository, thenApplyPresetsAsync()creates any preset child entities - Update/Delete/Restore: entity is already tracked by EF Core
SaveChangesAsync() commits via the boundary’s IUnitOfWork.
Batch mode: If BatchContext.Current is not null, the entity is accumulated and events are deferred — SaveChanges is skipped (the batch coordinator handles persistence).
7. Dispatch Domain Events
Section titled “7. Dispatch Domain Events”If the entity implements IHasDomainEvents and has pending events, they are dispatched via IDomainEventDispatcher and then cleared.
8. Cache Invalidation
Section titled “8. Cache Invalidation”If the mutation implements ICacheInvalidator, InvalidateAsync() is called with the resolved ICacheStack.
Navigation Includes
Section titled “Navigation Includes”When loading an entity for Update/Delete, specify navigation properties to eagerly load:
[Mutation(Mode = MutationMode.Update)][Include("Lines")][Include("Lines.Product")]public partial class UpdateOrder : Mutation<Order>{ public required Guid Id { get; init; } // ...}The generated invoker uses these to build the EF Core query with .Include() calls.
Return Type Control
Section titled “Return Type Control”The ReturnType property on [Mutation] controls what the endpoint returns after a successful mutation:
[Mutation(ReturnType = MutationReturnType.Id)] // default: returns entity ID[Mutation(ReturnType = MutationReturnType.Entity)] // returns full entity[Mutation(ReturnType = MutationReturnType.LogicalKey)] // returns business keyThis affects the generated endpoint response mapping, not the invoker itself (which always returns the entity).
Typed Errors
Section titled “Typed Errors”Mutations support up to 6 typed error variants, just like DomainActions:
// One error typepublic partial class CheckInGuest : Mutation<Reservation, ConflictError> { }
// Two error typespublic partial class TransferRoom : Mutation<Reservation, ConflictError, NotFoundError> { }The error types are used by the SG to generate accurate OpenAPI error schemas for endpoints.
Soft-Delete Compensation
Section titled “Soft-Delete Compensation”When a Delete mutation targets an entity with ISoftDelete, the invoker:
- Captures current soft-delete state (
IsDeleted,DeletedAt,DeletedBy) - Sets
IsDeleted = true,DeletedAt,DeletedBy - Calls
SaveChangesAsync() - If save fails, restores the captured state (compensation)
This ensures the in-memory entity stays consistent even if the database write fails. The same compensation pattern applies to Restore mutations.
For entities with [SoftDelete(Cascade = true)], the generated invoker overrides CompensateSoftDeleteCascade() to restore cascade targets as well.
Mutation Dependencies
Section titled “Mutation Dependencies”Like DomainActions, mutations resolve dependencies from private fields:
[Mutation(Mode = MutationMode.Update)]public partial class CheckInGuestMutation : Mutation<Reservation, ConflictError>{ private ICurrentUser _currentUser; // identity private IRepository<RoomAssignment, Guid> _roomAssignments; // sibling repo private IFeatureFlagStore _featureFlags; // feature flags
public required Guid Id { get; init; } // ...}The SG generates SetDependencies() that resolves each field. All dependencies within the same boundary share the same DbContext and IUnitOfWork, so creating related entities in ApplyAsync is transactionally safe.
Testing Mutations
Section titled “Testing Mutations”Unit Testing
Section titled “Unit Testing”Create the mutation, set its properties, and verify ApplyAsync behavior:
[Fact]public async Task CheckIn_ValidReservation_TransitionsToCheckedIn(){ var reservation = Reservation.Create(/* ... */); reservation.Confirm("agent"); // prerequisite state
var mutation = new CheckInGuestMutation { Id = reservation.Id }; // Inject test doubles for private fields via generated SetDependencies // or call ApplyAsync directly for pure logic testing
var result = await mutation.ApplyAsync(reservation);
result.IsSuccess.Should().BeTrue(); reservation.Status.Should().Be(ReservationStatus.CheckedIn);}Integration Testing
Section titled “Integration Testing”Use the invoker interface to test the full pipeline:
[Fact]public async Task CreateAmenity_WithValidInput_PersistsEntity(){ var invoker = _serviceProvider.GetRequiredService< IMutationInvoker<CreateAmenityMutation, Amenity>>();
var mutation = new CreateAmenityMutation { Name = "Pool", Category = AmenityCategory.Recreation };
var result = await invoker.InvokeAsync(mutation);
result.IsSuccess.Should().BeTrue(); result.Value.Name.Should().Be("Pool");}