Patch (Partial Updates)
Track which properties were explicitly set and apply only those — for true HTTP PATCH semantics.
The Problem
Section titled “The Problem”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.
The Solution: [Patch<TEntity>]
Section titled “The Solution: [Patch<TEntity>]”[Patch<UserProfile>]public partial class UpdateUserProfilePatch{ public string? FirstName { get; set; } public string? MiddleName { get; set; } public string? LastName { get; set; }}What the Source Generator Produces
Section titled “What the Source Generator Produces”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]”| Feature | Mutation<TEntity> + [Mutation] | [Patch<T>] |
|---|---|---|
| Tracks which properties are set | No (uses null check) | Yes (via _setProperties) |
| Can set property to null | No (null = “skip”) | Yes (null is a valid value) |
| Collection strategies | Replace/Merge/Append | Replace/Merge/Upsert |
| Modes (Create/Update/Delete) | Yes | No (always Update) |
| MutationInvoker pipeline | Yes | No (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.
Collection Strategies
Section titled “Collection Strategies”[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; }}Strategies
Section titled “Strategies”| Strategy | Attribute | Behavior |
|---|---|---|
| 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) |
Usage with JSON Deserialization
Section titled “Usage with JSON Deserialization”For HTTP PATCH endpoints, you need a custom JSON converter that calls MarkSet() for every property present in the JSON:
// Endpoint usageapp.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.