Skip to content

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.


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.

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

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 everywhere
public 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.


Pragmatic.Patch solves this with two pieces:

  1. Optional<T> — a readonly struct that carries three states: Undefined (not sent), Null (explicitly null), and Value (has a value)
  2. Source-generated patch DTOs — a single [GeneratePatch<TEntity>] attribute generates Optional<T> properties, ApplyTo(), ModifiedProperties, and a typed JSON converter
// Declare the patch type
[GeneratePatch<Guest>]
public partial record UpdateGuestPatch;
// Use it in an endpoint
app.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 inputResult
{ "firstName": "Alice" }FirstName = Optional.Of("Alice"), Email = Optional.Undefined
{ "email": null }Email = Optional.Null, FirstName = Optional.Undefined
{}All fields Optional.Undefined — no changes applied

[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> is the core type. It is a readonly struct with zero heap allocation.

StateHasValueIsUndefinedValueMeaning
Undefinedfalsetruethrows InvalidOperationExceptionField was not in the JSON payload
NulltruefalsenullField was explicitly set to null
Valuetruefalsethe valueField was set to a specific value
Optional<string>.Undefined // Not sent -- same as default(Optional<T>)
Optional<string>.Null // Explicitly null
Optional<string>.Of("Alice") // Has value
Optional<string>.Of(null) // Same as Null
Optional<string> name = "Alice"; // Implicit from T to Optional<T>
MemberReturnsDescription
HasValuebooltrue if the field was sent (including null)
IsUndefinedbooltrue if the field was not sent at all
ValueT?The value. Throws InvalidOperationException if undefined
GetValueOrDefault(T?)T?Value if present, otherwise the default
IfPresent(Action<T?>)voidExecutes the action only when HasValue
Map<TResult>(Func<T?, TResult?>)Optional<TResult>Transforms the value, propagates Undefined

Optional<T> implements IEquatable<Optional<T>> with value semantics:

  • Two Undefined are equal
  • Two values are equal when EqualityComparer<T>.Default says so
  • Undefined is never equal to Null
  • Optional.Of(null) equals Optional.Null
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.

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

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.

For each property, the generator checks:

  1. Set{PropertyName}() method — preferred. Uses the entity’s encapsulated setter (e.g., entity.SetName(value)).
  2. 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.

The generator automatically skips properties that should not be patchable:

CategoryProperties
IdentityId, PersistenceId
Auditing (IAuditable)CreatedAt, CreatedBy, UpdatedAt, UpdatedBy
Soft Delete (ISoftDelete)IsDeleted, DeletedAt, DeletedBy
ConcurrencyRowVersion
NavigationCollection properties (ICollection<T>, IList<T>, IEnumerable<T>)
Entity referencesReference 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.


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:

  • PropertyNamingPolicy awareness: 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 to Optional.Null, absent key to Optional.Undefined

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)

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.


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 — an IReadOnlySet<string> tracking which properties were explicitly marked
  • MarkSet(propertyName) — marks a property as explicitly set
  • ApplyPatch(entity) — applies only tracked properties, respecting entity setters and converters

This dual approach supports both:

  • HTTP PATCH endpoints via [GeneratePatch<T>] with Optional<T> tri-state
  • Mutation partial updates via SetProperties tracking in the persistence layer

When ApplyTo() calls the entity’s Set*() methods:

  • IChangeTracking.ModifiedProperties is updated automatically
  • Only modified properties are validated
  • EF Core tracks only changed columns
  • EntityPropertyChanged<T> events fire only for actual changes

IDSeverityDescription
PRAG1900Error[GeneratePatch<T>] must be applied to a partial class or record
PRAG1901ErrorEntity type could not be resolved from the attribute type argument
PRAG1902WarningEntity has no settable properties suitable for patching

TypeNamespacePurpose
Optional<T>Pragmatic.PatchTri-state readonly struct
GeneratePatchAttribute<TEntity>Pragmatic.Patch.AttributesTriggers SG for patch DTO generation
OptionalConverterFactoryPragmatic.Patch.SerializationFallback JSON converter factory for Optional<T>

  • Zero reflection — all property mapping is resolved at compile time by the source generator
  • Zero allocationOptional<T> is a readonly struct, no heap allocation
  • Type safety — the generated converter is typed per patch DTO, no MakeGenericType at runtime
  • Encapsulation — prefers Set* methods over public setters, respecting entity invariants
  • Composable — works standalone or integrated with Pragmatic.Persistence mutation pipeline