Skip to content

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.

Use CaseChoose
Standard entity CRUD (create, update, delete)Mutation<TEntity>
Query / read operationDomainAction<TReturn>
Complex orchestration across multiple entitiesDomainAction<TReturn>
Operation that doesn’t modify a single entityDomainAction<TReturn> or VoidDomainAction
Upsert with non-PK lookupDomainAction<TReturn> (use CreateOrUpdate mode only for PK-based)

The MutationMode enum controls how the invoker handles the entity:

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.

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.

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

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).

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

If Mode is not set explicitly, it is inferred from the class name prefix:

  • Create{Entity}... -> MutationMode.Create
  • Update{Entity}... -> MutationMode.Update
  • Delete{Entity}... -> MutationMode.Delete

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 Name maps to entity method SetName()
  • Mutation property Category maps to entity method SetCategory()
  • 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);
}
}

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.


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

When both auto-mapping and ApplyAsync are present, the execution order is:

  1. ApplyToEntity(entity) — auto-mapped setters run first
  2. ApplyAsync(entity, ct) — your custom logic runs second

This means entity already has the auto-mapped properties set when ApplyAsync receives it.

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

Here is the complete pipeline executed by MutationInvoker<TMutation, TEntity>:

The generated invoker calls InjectDependencies(mutation) which resolves all private fields from DI.

If the mutation implements ISyncValidator (generated from validation attributes), Validate() is called. Failure short-circuits with ValidationError.

If an IAsyncValidator<TMutation> is registered in DI, ValidateAsync() is called. Failure short-circuits.

Any IActionFilter<TMutation> registered in DI runs (ordered by Order). These can short-circuit with business rule errors.

Based on MutationMode:

  • Create: calls CreateEntity() (typically new 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).

mutation.ApplyToEntity(entity) runs — the SG-generated property mapping via entity.SetX() calls.

mutation.ApplyAsync(entity, ct) runs — the developer’s override (or no-op default).

After changes are applied, entity validation runs:

  • First tries IValidator<TEntity> from DI (unified sync + async, change-aware)
  • Falls back to ISyncValidator on the entity itself

Change-awareness: For updates, IChangeTracking.ModifiedProperties is passed to the validator, allowing it to validate only modified fields.

  • Create: PersistNew(entity) adds to repository, then ApplyPresetsAsync() 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).

If the entity implements IHasDomainEvents and has pending events, they are dispatched via IDomainEventDispatcher and then cleared.

If the mutation implements ICacheInvalidator, InvalidateAsync() is called with the resolved ICacheStack.


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.


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 key

This affects the generated endpoint response mapping, not the invoker itself (which always returns the entity).


Mutations support up to 6 typed error variants, just like DomainActions:

// One error type
public partial class CheckInGuest : Mutation<Reservation, ConflictError> { }
// Two error types
public partial class TransferRoom : Mutation<Reservation, ConflictError, NotFoundError> { }

The error types are used by the SG to generate accurate OpenAPI error schemas for endpoints.


When a Delete mutation targets an entity with ISoftDelete, the invoker:

  1. Captures current soft-delete state (IsDeleted, DeletedAt, DeletedBy)
  2. Sets IsDeleted = true, DeletedAt, DeletedBy
  3. Calls SaveChangesAsync()
  4. 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.


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.


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

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