Skip to content

Patch (Partial Updates)

Track which properties were explicitly set and apply only those — for true HTTP PATCH semantics.

Mutation<TEntity> + [Mutation] uses nullable properties to represent partial updates: if a property is null, it’s not changed. But this approach has a limitation — you can’t distinguish between “the client didn’t send this field” and “the client sent null to clear this field”.

Consider updating a user profile:

// Client wants to clear the middle name
{ "middleName": null }
// Client didn't send middleName at all (should keep existing value)
{ "firstName": "Jane" }

With nullable properties alone, both cases look identical — MiddleName is null.

[Patch<UserProfile>]
public partial class UpdateUserProfilePatch
{
public string? FirstName { get; set; }
public string? MiddleName { get; set; }
public string? LastName { get; set; }
}
public partial class UpdateUserProfilePatch
{
private HashSet<string> _setProperties = new();
public IReadOnlySet<string> SetProperties => _setProperties;
public void MarkSet(string propertyName) => _setProperties.Add(propertyName);
public void ApplyPatch(UserProfile target)
{
if (_setProperties.Contains(nameof(FirstName)))
target.SetFirstName(FirstName);
if (_setProperties.Contains(nameof(MiddleName)))
target.SetMiddleName(MiddleName); // Can set to null!
if (_setProperties.Contains(nameof(LastName)))
target.SetLastName(LastName);
}
}

Key Difference from Mutation<TEntity> + [Mutation]

Section titled “Key Difference from Mutation<TEntity> + [Mutation]”
FeatureMutation<TEntity> + [Mutation][Patch<T>]
Tracks which properties are setNo (uses null check)Yes (via _setProperties)
Can set property to nullNo (null = “skip”)Yes (null is a valid value)
Collection strategyDerives Sync — what is not sent is removedDerives AddOnly — nothing is removed
Modes (Create/Update/Delete)YesNo (always Update)
MutationInvoker pipelineYesNo (manual apply)

When to use which:

  • Mutation<TEntity> + [Mutation] — Most cases. Handles the full lifecycle (create, update, delete, restore) with generated invoker pipeline.
  • [Patch<T>] — When you need true PATCH semantics: distinguish “not sent” from “sent as null”. Typically for public APIs.

[Patch<T>] supports collection strategies via the same attributes:

[Patch<Order>]
public partial class PatchOrder
{
public string? OrderNumber { get; set; }
[CollectionStrategy(CollectionStrategy.Replace)]
public List<PatchLineItem>? LineItems { get; set; }
}
[Patch<LineItem>]
public partial class PatchLineItem
{
public decimal? Price { get; set; }
public int? Quantity { get; set; }
}

You do not declare one. A patch is a partial representation, so a collection it carries derives AddOnly: what is there is updated, what is new is added, and nothing is removed — a child the caller did not mention is a child the caller said nothing about.

Elements are matched by the element DTO’s Id, or failing that by the child entity’s [LogicKey]. With neither there is nothing to match on, and the generator reports PRAG2203 rather than guessing.

[CollectionStrategy] overrides the derived value where the default reads the operation wrong:

[CollectionStrategy(CollectionStrategy.Sync)] // this patch really does settle the whole set
public List<OrderLineDto>? Lines { get; init; }
CollectionStrategyBehaviour
AddOnlyUpdate by key, add what is new, remove nothing — the derived default here
SyncAlso removes what was not sent
ReplaceDiscard every child and rebuild — new rows, new identities
IgnoreLeave the collection alone

The generator emits a System.Text.Json converter next to ApplyPatch{Type}.PatchJsonConverter, declared on the type with [JsonConverter] — that calls MarkSet() for every property the body names. A property sent as null is marked and cleared; a property the body does not mention is not marked and keeps whatever the entity has. Nothing to register: the attribute is honoured by whatever options read the body, including the endpoint pipeline.

// Endpoint usage
app.MapPatch("/users/{id}", async (
Guid id,
UpdateUserProfilePatch patch,
IRepository<UserProfile> repo,
IServiceProvider services,
CancellationToken ct) =>
{
var user = await repo.GetByIdAsync(id, ct);
if (user is null) return Results.NotFound();
patch.ApplyPatch(user); // Only modifies explicitly set properties
var uow = services.GetRequiredKeyedService<IUnitOfWork>(typeof(ProfileBoundary));
await uow.SaveChangesAsync(ct);
return Results.Ok();
});

⚠️ A patch built in code, not read from JSON, marks nothing. With _setProperties empty ApplyPatch falls back to a null check — the [Mutation] semantics — so call MarkSet() yourself for the properties you mean to write, null included. Property names are matched as declared and in camelCase; [JsonPropertyName] is not read.

The other tri-state form is [GeneratePatch<TEntity>] (the separate Pragmatic.Patch package): you declare an empty partial record and it generates the whole DTO with Optional<T> properties, ApplyTo(entity), GetModifiedProperties() and its own JSON converter. See Pragmatic.Patch/docs/tri-state-semantics.md.