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 strategiesReplace/Merge/AppendReplace/Merge/Upsert
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; }
[ReplaceCollection]
public List<PatchLineItem>? Items { get; set; }
}
[Patch<LineItem>]
public partial class PatchLineItem
{
public decimal? Price { get; set; }
public int? Quantity { get; set; }
}
StrategyAttributeBehavior
Replace[ReplaceCollection]Remove all existing, add all from patch
Merge[MergeCollection(KeyProperty = "Id")]Match by key: update existing, add new, remove missing
Upsert[UpsertCollection(KeyProperty = "Id")]Match by key: update existing, add new (don’t remove)

For HTTP PATCH endpoints, you need a custom JSON converter that calls MarkSet() for every property present in the JSON:

// Endpoint usage
app.MapPatch("/users/{id}", async (
Guid id,
UpdateUserProfilePatch patch,
IRepository<UserProfile, Guid> 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();
});

The JSON deserializer integration marks each deserialized property via MarkSet(), so ApplyPatch() knows exactly which properties the client intended to change.