Architecture and Core Concepts
This guide explains why Pragmatic.Patch exists, how Optional<T> solves the tri-state problem, and how the source generator turns a one-line declaration into a complete PATCH DTO. Read this before diving into the individual feature guides.
The Problem
Section titled “The Problem”HTTP PATCH operations need to distinguish three states for every field: the field was not sent (leave unchanged), sent as null (clear the value), or sent with a value (update it). Standard C# nullability cannot express all three.
The null ambiguity
Section titled “The null ambiguity”Consider a PATCH request to update a guest profile:
{ "firstName": "Alice" }This should update FirstName but leave Email unchanged. But with a normal DTO:
public record UpdateGuest{ public string? FirstName { get; init; } // "Alice" public string? Email { get; init; } // null -- but does this mean "clear" or "not sent"?}When Email is null, there is no way to tell if the client explicitly sent null (meaning “clear my email”) or simply omitted the field (meaning “don’t touch it”). Both states collapse into the same null value.
The value-type trap
Section titled “The value-type trap”The problem gets worse with non-nullable value types:
public record UpdateProduct{ public decimal Price { get; init; } // 0 -- was this sent or is it the default? public int Stock { get; init; } // 0 -- same problem}An empty JSON body produces Price = 0 and Stock = 0. A naive ApplyTo method would zero out every numeric field.
Manual workarounds are fragile
Section titled “Manual workarounds are fragile”Without a tri-state type, developers resort to workarounds:
// Workaround 1: Separate "modified fields" set (manual tracking)public record UpdateGuest{ public string? FirstName { get; init; } public HashSet<string> ModifiedFields { get; init; } = new();}
// Workaround 2: Nullable wrappers everywherepublic record UpdateProduct{ public decimal? Price { get; init; } // But now "null" means "not sent", not "clear"}
// Workaround 3: JsonPatchDocument (loses type safety)app.MapPatch("/products/{id}", (Guid id, JsonPatchDocument<Product> patch) => ...);Each workaround introduces its own problems: manual tracking is error-prone, nullable wrappers flip the semantics, and JsonPatchDocument loses type safety and requires the caller to understand JSON Patch operations.
The Solution
Section titled “The Solution”Pragmatic.Patch solves this with two pieces:
Optional<T>— a readonly struct that carries three states: Undefined (not sent), Null (explicitly null), and Value (has a value)- Source-generated patch DTOs — a single
[GeneratePatch<TEntity>]attribute generatesOptional<T>properties,ApplyTo(),ModifiedProperties, and a typed JSON converter
// Declare the patch type[GeneratePatch<Guest>]public partial record UpdateGuestPatch;
// Use it in an endpointapp.MapPatch("/api/guests/{id}", async (Guid id, UpdateGuestPatch patch, AppDbContext db) =>{ var guest = await db.Guests.FindAsync(id); if (guest is null) return Results.NotFound();
patch.ApplyTo(guest); // Only modifies fields that were sent await db.SaveChangesAsync(); return Results.Ok(guest);});The generated JSON converter handles all three states automatically:
| JSON input | Result |
|---|---|
{ "firstName": "Alice" } | FirstName = Optional.Of("Alice"), Email = Optional.Undefined |
{ "email": null } | Email = Optional.Null, FirstName = Optional.Undefined |
{} | All fields Optional.Undefined — no changes applied |
How It Works
Section titled “How It Works”Step 1: You declare a partial record
Section titled “Step 1: You declare a partial record”[GeneratePatch<Product>]public partial record PatchProduct;Step 2: The source generator emits two files
Section titled “Step 2: The source generator emits two files”Patch type (PatchProduct.Patch.g.cs):
public partial record PatchProduct{ public Optional<string> Name { get; init; } public Optional<string?> Description { get; init; } public Optional<decimal> Price { get; init; }
public void ApplyTo(Product entity) { if (Name.HasValue) entity.SetName(Name.Value!); if (Description.HasValue) entity.SetDescription(Description.Value); if (Price.HasValue) entity.SetPrice(Price.Value); }
public IReadOnlySet<string> ModifiedProperties => GetModifiedProperties();}JSON converter (PatchProduct.JsonConverter.g.cs):
A typed System.Text.Json converter that:
- Tracks which JSON keys were present during deserialization
- Present key with value:
Optional<T>.Of(value) - Present key with
null:Optional<T>.Null - Absent key:
Optional<T>.Undefined(default) - Respects
JsonSerializerOptions.PropertyNamingPolicy(camelCase-aware)
The converter is applied via [JsonConverter] attribute on the partial record, so no manual registration is needed.
Step 3: ASP.NET Core model binding does the rest
Section titled “Step 3: ASP.NET Core model binding does the rest”When a PATCH request arrives, the System.Text.Json deserializer calls the generated converter, which sets each property to the correct tri-state value. ApplyTo() then applies only the fields where HasValue is true.
Optional<T>
Section titled “Optional<T>”Optional<T> is the core type. It is a readonly struct with zero heap allocation.
Three States
Section titled “Three States”| State | HasValue | IsUndefined | Value | Meaning |
|---|---|---|---|---|
| Undefined | false | true | throws InvalidOperationException | Field was not in the JSON payload |
| Null | true | false | null | Field was explicitly set to null |
| Value | true | false | the value | Field was set to a specific value |
Factory Methods
Section titled “Factory Methods”Optional<string>.Undefined // Not sent -- same as default(Optional<T>)Optional<string>.Null // Explicitly nullOptional<string>.Of("Alice") // Has valueOptional<string>.Of(null) // Same as NullImplicit Conversion
Section titled “Implicit Conversion”Optional<string> name = "Alice"; // Implicit from T to Optional<T>Instance Members
Section titled “Instance Members”| Member | Returns | Description |
|---|---|---|
HasValue | bool | true if the field was sent (including null) |
IsUndefined | bool | true if the field was not sent at all |
Value | T? | The value. Throws InvalidOperationException if undefined |
GetValueOrDefault(T?) | T? | Value if present, otherwise the default |
IfPresent(Action<T?>) | void | Executes the action only when HasValue |
Map<TResult>(Func<T?, TResult?>) | Optional<TResult> | Transforms the value, propagates Undefined |
Equality
Section titled “Equality”Optional<T> implements IEquatable<Optional<T>> with value semantics:
- Two
Undefinedare equal - Two values are equal when
EqualityComparer<T>.Defaultsays so Undefinedis never equal toNullOptional.Of(null)equalsOptional.Null
ToString
Section titled “ToString”Optional<string>.Of("Alice").ToString() // "Optional(Alice)"Optional<string>.Null.ToString() // "Optional(null)"Optional<string>.Undefined.ToString() // "Undefined"HasValue vs IsUndefined vs Checking for Null
Section titled “HasValue vs IsUndefined vs Checking for Null”These three checks cover different scenarios. Understanding the distinction is essential for correct PATCH handling.
HasValue: “Was this field sent?”
Section titled “HasValue: “Was this field sent?””if (patch.Email.HasValue){ // The client explicitly included "email" in the JSON body. // The value might be null (meaning "clear") or a string (meaning "update").}IsUndefined: “Was this field omitted?”
Section titled “IsUndefined: “Was this field omitted?””if (patch.Email.IsUndefined){ // The client did not include "email" in the JSON body. // Do not touch the entity's Email property.}Checking Value for null: “Did the client send null?”
Section titled “Checking Value for null: “Did the client send null?””if (patch.Email.HasValue && patch.Email.Value is null){ // The client explicitly sent { "email": null } // Clear the entity's Email property.}Combined example
Section titled “Combined example”if (patch.Email.IsUndefined) logger.LogDebug("Email not included in patch");else if (patch.Email.Value is null) logger.LogInformation("Guest {Id} cleared their email", id);else logger.LogInformation("Guest {Id} changed email to {Email}", id, patch.Email.Value);ApplyTo Pattern
Section titled “ApplyTo Pattern”The generated ApplyTo(TEntity entity) method applies only the fields where HasValue is true. It respects the entity’s encapsulation by preferring Set* methods over public setters.
Property Setter Resolution
Section titled “Property Setter Resolution”For each property, the generator checks:
Set{PropertyName}()method — preferred. Uses the entity’s encapsulated setter (e.g.,entity.SetName(value)).- Public setter — fallback when no
Set*method exists (e.g.,entity.Name = value).
If a property has both a Set* method and a public setter, the Set* method takes priority. This integrates with Pragmatic.Persistence’s generated entity setters.
Excluded Properties
Section titled “Excluded Properties”The generator automatically skips properties that should not be patchable:
| Category | Properties |
|---|---|
| Identity | Id, PersistenceId |
Auditing (IAuditable) | CreatedAt, CreatedBy, UpdatedAt, UpdatedBy |
Soft Delete (ISoftDelete) | IsDeleted, DeletedAt, DeletedBy |
| Concurrency | RowVersion |
| Navigation | Collection properties (ICollection<T>, IList<T>, IEnumerable<T>) |
| Entity references | Reference types that have an Id or PersistenceId property |
These exclusions apply regardless of whether the entity explicitly implements the interfaces — the property names alone trigger exclusion.
JSON Serialization
Section titled “JSON Serialization”Generated Typed Converter
Section titled “Generated Typed Converter”The source generator produces a typed JsonConverter per patch type. This converter is automatically applied via the [JsonConverter] attribute on the generated partial record.
The converter handles:
PropertyNamingPolicyawareness: both PascalCase and camelCase property names work in the same request- Tracking which keys were present during deserialization
- Correct mapping: present key with value to
Optional.Of(), present key with null toOptional.Null, absent key toOptional.Undefined
Non-Nullable Value Types
Section titled “Non-Nullable Value Types”For non-nullable value types like decimal, sending null in JSON results in Undefined (not Null), because null is not a valid value for decimal. This prevents accidental zeroing:
{ "price": null } // Price stays Undefined (decimal cannot be null){ "price": 0 } // Price = Optional.Of(0m) (explicit zero)OptionalConverterFactory (Fallback)
Section titled “OptionalConverterFactory (Fallback)”For scenarios where you use Optional<T> outside of a generated patch DTO (e.g., in a manually-written DTO), register the fallback converter factory globally:
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new OptionalConverterFactory()));The factory uses MakeGenericType at runtime, so it is not AOT-safe. For NativeAOT scenarios, register typed OptionalConverter<T> instances directly. The SG-generated converters are always AOT-safe.
Integration with Pragmatic.Persistence
Section titled “Integration with Pragmatic.Persistence”When using Pragmatic.Persistence, the source generator also generates a PatchApplyTemplate for mutation classes. This uses SetProperties tracking with MarkSet() instead of Optional<T>:
SetProperties— anIReadOnlySet<string>tracking which properties were explicitly markedMarkSet(propertyName)— marks a property as explicitly setApplyPatch(entity)— applies only tracked properties, respecting entity setters and converters
This dual approach supports both:
- HTTP PATCH endpoints via
[GeneratePatch<T>]withOptional<T>tri-state - Mutation partial updates via
SetPropertiestracking in the persistence layer
Change Tracking Integration
Section titled “Change Tracking Integration”When ApplyTo() calls the entity’s Set*() methods:
IChangeTracking.ModifiedPropertiesis updated automatically- Only modified properties are validated
- EF Core tracks only changed columns
EntityPropertyChanged<T>events fire only for actual changes
Diagnostics
Section titled “Diagnostics”| ID | Severity | Description |
|---|---|---|
| PRAG1900 | Error | [GeneratePatch<T>] must be applied to a partial class or record |
| PRAG1901 | Error | Entity type could not be resolved from the attribute type argument |
| PRAG1902 | Warning | Entity has no settable properties suitable for patching |
Key Types
Section titled “Key Types”| Type | Namespace | Purpose |
|---|---|---|
Optional<T> | Pragmatic.Patch | Tri-state readonly struct |
GeneratePatchAttribute<TEntity> | Pragmatic.Patch.Attributes | Triggers SG for patch DTO generation |
OptionalConverterFactory | Pragmatic.Patch.Serialization | Fallback JSON converter factory for Optional<T> |
Design Principles
Section titled “Design Principles”- Zero reflection — all property mapping is resolved at compile time by the source generator
- Zero allocation —
Optional<T>is areadonly struct, no heap allocation - Type safety — the generated converter is typed per patch DTO, no
MakeGenericTypeat runtime - Encapsulation — prefers
Set*methods over public setters, respecting entity invariants - Composable — works standalone or integrated with Pragmatic.Persistence mutation pipeline
See Also
Section titled “See Also”- Getting Started — Step-by-step guide to adding PATCH support
- Tri-State Semantics — Detailed
Optional<T>API and JSON behavior - Common Mistakes — Pitfalls and how to avoid them
- Troubleshooting — Problem/solution guide