A source-generated PATCH semantics library for .NET. Define your patch DTOs with Option<T> properties, and the generator writes the boring mapping code at build time — zero reflection, zero runtime cost.
- Installation
- Quick Start
- Step 1: Define Your Entities
- Step 2: Define Patch DTOs
- Step 3: Apply Patches to Entities
- Step 4: Generate Patch Entries (for Cosmos DB, JSON Patch, etc.)
- Nested DTOs
- Cosmos DB Integration
- JSON Property Name Support
- Diagnostics
- How It Works
- Limitations
Install the NuGet package:
dotnet add package SelmanMade.OptionPatchOr via the Package Manager Console in Visual Studio:
Install-Package SelmanMade.OptionPatchThe package includes both the runtime types (Option<T>, PatchEntry, attributes) and the source generator. No additional packages are needed.
using OptionPatch;
// 1. Mark your patch DTO with the target entity type
[GeneratePatchApplier(typeof(Person))]
public class UpdatePerson
{
public Option<string?> Name { get; set; }
public Option<int?> Age { get; set; }
}
// 2. Apply a patch — only set properties are touched
var person = new Person { Name = "Alice", Age = 30 };
var patch = new UpdatePerson { Name = "Bob" };
UpdatePersonPatchApplier.Apply(patch, person);
// person.Name == "Bob", person.Age == 30 (unchanged)
// 3. Or generate path-based entries for Cosmos DB / JSON Patch
var entries = UpdatePersonPatchApplier.ToPatchEntries(patch);
// entries[0]: Set(/name, Bob)These are your normal domain/entity classes. Nothing special required.
public class Person
{
public string? Name { get; set; }
public int? Age { get; set; }
public Address? Address { get; set; }
}
public class Address
{
public string? Street { get; set; }
public string? City { get; set; }
}Create a class for each entity you want to patch. Use Option<T> for every property and annotate the class with [GeneratePatchApplier(typeof(TargetEntity))].
using OptionPatch;
[GeneratePatchApplier(typeof(Person))]
public class UpdatePerson
{
public Option<string?> Name { get; set; }
public Option<int?> Age { get; set; }
public Option<UpdateAddress?> Address { get; set; }
}
[GeneratePatchApplier(typeof(Address))]
public class UpdateAddress
{
public Option<string?> Street { get; set; }
public Option<string?> City { get; set; }
}Key rules:
| Rule | Why |
|---|---|
| Property names must match the entity | The generator matches by name |
| Entity properties must have a setter | Read-only properties are skipped (with a warning) |
Use Option<T> from OptionPatch |
The generator only processes properties with the [Option]-marked struct |
Option<T> distinguishes between "not provided" and "explicitly set to null" — the core problem with PATCH APIs:
var patch = new UpdatePerson();
// patch.Name.IsSet == false → "Name was not in the request, don't touch it"
var patch2 = new UpdatePerson { Name = "Alice" };
// patch2.Name.IsSet == true, patch2.Name.Value == "Alice" → "Set Name to Alice"
var patch3 = new UpdatePerson { Name = (string?)null };
// patch3.Name.IsSet == true, patch3.Name.Value == null → "Clear Name"At build time, the generator creates a static {DtoName}PatchApplier class with an Apply method:
var person = new Person { Name = "Alice", Age = 30 };
var patch = new UpdatePerson { Name = "Bob" };
// Age is not set — it won't be touched
UpdatePersonPatchApplier.Apply(patch, person);
// person.Name == "Bob"
// person.Age == 30 (unchanged)Only properties where IsSet == true are applied. Everything else is left untouched.
The generator also creates a ToPatchEntries method that produces a list of PatchEntry objects — a generic intermediate representation with JSON paths and values. This is what you use for Cosmos DB, JSON Patch, or any path-based patch format.
var patch = new UpdatePerson
{
Name = "Bob",
Age = (int?)null
};
IReadOnlyList<PatchEntry> entries = UpdatePersonPatchApplier.ToPatchEntries(patch);
// entries[0]: Set(/name, Bob)
// entries[1]: Set(/age, null)Each PatchEntry has:
| Property | Type | Description |
|---|---|---|
Path |
string |
JSON pointer path (e.g. /name, /address/city) |
Value |
object? |
The value to set, or null |
IsRemoval |
bool |
true if this is a remove operation |
When a patch DTO property is itself a patch DTO (annotated with [GeneratePatchApplier]), the generator handles nesting automatically.
var patch = new UpdatePerson
{
Address = new UpdateAddress { City = "Shelbyville" }
};
// Apply: creates Address if null, updates only City
UpdatePersonPatchApplier.Apply(patch, person);
// ToPatchEntries: produces flattened path
var entries = UpdatePersonPatchApplier.ToPatchEntries(patch);
// entries[0]: Set(/address/city, Shelbyville)var patch = new UpdatePerson
{
Address = (UpdateAddress?)null
};
// Apply: sets person.Address = null
UpdatePersonPatchApplier.Apply(patch, person);
// ToPatchEntries: produces a Remove entry
var entries = UpdatePersonPatchApplier.ToPatchEntries(patch);
// entries[0]: Remove(/address)var patch = new UpdatePerson
{
Name = "Bob"
// Address not set at all — completely untouched
};PatchEntry is deliberately free of any Cosmos DB dependency. You write a thin adapter in your API layer:
using Microsoft.Azure.Cosmos;
using OptionPatch;
public static class CosmosPatchAdapter
{
public static IReadOnlyList<PatchOperation> ToPatchOperations(
IReadOnlyList<PatchEntry> entries)
{
var ops = new List<PatchOperation>(entries.Count);
foreach (var entry in entries)
{
ops.Add(entry.IsRemoval
? PatchOperation.Remove(entry.Path)
: PatchOperation.Set(entry.Path, entry.Value));
}
return ops;
}
}Usage:
var patch = new UpdatePerson
{
Name = "Alice",
Address = (UpdateAddress?)null
};
var entries = UpdatePersonPatchApplier.ToPatchEntries(patch);
var ops = CosmosPatchAdapter.ToPatchOperations(entries);
await container.PatchItemAsync<Person>(id, partitionKey, ops);
// Sends: Set(/name, "Alice"), Remove(/address)This same pattern works for any patch target — JSON Patch (RFC 6902), MongoDB updates, or a custom format. Write one adapter per target.
The generator resolves JSON property names for ToPatchEntries paths using this order:
[JsonPropertyName("...")]on the entity property — if present, uses the specified name- camelCase fallback — converts the C# property name (e.g.
ZipCode→zipCode)
Example with explicit JSON names:
using System.Text.Json.Serialization;
public class Person
{
[JsonPropertyName("full_name")]
public string? Name { get; set; }
public int? Age { get; set; }
}Generated paths will be /full_name and /age.
The generator emits build-time diagnostics when something doesn't map correctly:
| ID | Severity | Description |
|---|---|---|
| PATCH001 | Error | Property types are incompatible (e.g. Option<string> → int target) |
| PATCH002 | Warning | DTO property has no matching writable property on the target entity |
These appear as regular build errors/warnings in Visual Studio, so you catch mapping mistakes at compile time instead of runtime.
At build time, the incremental source generator:
- Scans for classes annotated with
[GeneratePatchApplier(typeof(T))] - Matches each
Option<T>property by name to the target entity's properties - Emits a
{DtoName}PatchApplier.g.csfile with two methods:Apply(patch, target)— direct property assignment on the entityToPatchEntries(patch, prefix)— JSON path + value list
You can inspect the generated files in your project's obj folder or via Visual Studio's Analyzers node in Solution Explorer.
The generated code is plain C# with no reflection, no expression trees, and no runtime compilation.
This version does not yet handle:
- Collections or dictionaries
- Custom property name mappings on the DTO side
- Immutable record targets
- Constructor-based target creation
- Cycle detection in nested DTOs
System.Text.Jsonconverter for deserializingOption<T>from JSON