Tri-State Semantics and Optional<T>
HTTP PATCH operations need to distinguish three states: a field was not sent (leave unchanged), sent as null (clear the value), or sent with a value (update). Standard C# nullability cannot express “not sent” vs “null”. Optional<T> solves this with a tri-state struct.
The Problem
Section titled “The Problem”Consider a PATCH request to update a product:
{ "name": "New Name" }This should update Name but leave Description unchanged. But with a normal DTO:
public record UpdateProduct{ public string? Name { get; init; } // "New Name" public string? Description { get; init; } // null — but does this mean "clear" or "not sent"?}There is no way to tell if Description was explicitly set to null or simply absent from the JSON.
Optional
Section titled “Optional”Optional<T> is a readonly struct with three states:
| State | HasValue | IsUndefined | Value | Meaning |
|---|---|---|---|---|
| Undefined | false | true | throws | 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 sentOptional<string>.Null // Sent as nullOptional<string>.Of("Alice") // Sent with valueOptional<string>.Of(null) // Same as Null
// Implicit conversion from TOptional<string> name = "Alice"; // Of("Alice")Querying
Section titled “Querying”var name = Optional<string>.Of("Alice");
if (name.HasValue) Console.WriteLine(name.Value); // "Alice"
// Safe access with defaultstring display = name.GetValueOrDefault("Unknown");
// Functional stylename.IfPresent(n => Console.WriteLine(n));
// TransformOptional<int> length = name.Map(n => n?.Length ?? 0);JSON Serialization
Section titled “JSON Serialization”The OptionalConverterFactory handles JSON deserialization with correct tri-state mapping:
| JSON | Deserialized to |
|---|---|
{ "name": "Alice" } | Name = Optional.Of("Alice"), Email = Optional.Undefined |
{ "name": null } | Name = Optional.Null |
{ } | Name = Optional.Undefined, Email = Optional.Undefined |
The converter is registered automatically on SG-generated patch types via [JsonConverter] attribute.
Source-Generated Patch Types
Section titled “Source-Generated Patch Types”Mark a record with [GeneratePatch<TEntity>] and the SG generates a complete patch DTO:
[GeneratePatch<Product>]public partial record PatchProduct;Generated Code
Section titled “Generated Code”public partial record PatchProduct{ [JsonConverter(typeof(OptionalConverter<string>))] public Optional<string> Name { get; init; }
[JsonConverter(typeof(OptionalConverter<string?>))] public Optional<string?> Description { get; init; }
[JsonConverter(typeof(OptionalConverter<decimal>))] 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 => /* tracks which properties were in the JSON */;}Property Exclusion
Section titled “Property Exclusion”The generator automatically excludes:
Id,PersistenceId— identity cannot changeCreatedAt,CreatedBy,UpdatedAt,UpdatedBy— managed by auditingIsDeleted,DeletedAt,DeletedBy— managed by soft deleteRowVersion— managed by concurrency- Collection navigations — not patchable via simple PATCH
- Reference navigations — use explicit FK properties instead
Using Patch in Endpoints
Section titled “Using Patch in Endpoints”[Endpoint(HttpVerb.Patch, "/api/products/{productId}")]public partial class PatchProductEndpoint : Endpoint<ProductDto>{ private IRepository<Product, Guid> _products; private IUnitOfWork _uow;
[FromRoute] public required Guid ProductId { get; init; } [FromBody] public required PatchProduct Patch { get; init; }
public override async Task<Result<ProductDto, NotFoundError>> HandleAsync(CancellationToken ct) { var product = await _products.GetByIdAsync(ProductId, ct); if (product is null) return new NotFoundError("Product", ProductId.ToString());
Patch.ApplyTo(product); await _uow.SaveChangesAsync(ct);
return ProductDto.FromEntity(product); }}Request Examples
Section titled “Request Examples”### Update only the namePATCH /api/products/abc123Content-Type: application/json
{ "name": "Updated Product" }
### Clear the descriptionPATCH /api/products/abc123Content-Type: application/json
{ "name": "Updated", "description": null }
### No changes (valid but no-op)PATCH /api/products/abc123Content-Type: application/json
{ }Integration with Change Tracking
Section titled “Integration with Change Tracking”When ApplyTo() calls the entity’s Set*() methods, IChangeTracking.ModifiedProperties is updated automatically. This means:
- Selective validation — only modified properties are validated
- Optimized persistence — EF Core tracks only changed columns
- Audit trail —
EntityPropertyChanged<T>events fire only for actual changes
Diagnostics
Section titled “Diagnostics”| ID | Severity | Description |
|---|---|---|
PRAG1900 | Error | [GeneratePatch<T>] type must be partial |
PRAG1901 | Error | Entity type could not be resolved |
PRAG1902 | Warning | Entity has no settable properties (patch would be empty) |