Patchly 0.4.0
dotnet add package Patchly --version 0.4.0
NuGet\Install-Package Patchly -Version 0.4.0
<PackageReference Include="Patchly" Version="0.4.0" />
<PackageVersion Include="Patchly" Version="0.4.0" />
<PackageReference Include="Patchly" />
paket add Patchly --version 0.4.0
#r "nuget: Patchly, 0.4.0"
#:package Patchly@0.4.0
#addin nuget:?package=Patchly&version=0.4.0
#tool nuget:?package=Patchly&version=0.4.0
![]()
π©Ή Patchly
The null vs "I didn't send this" problem β solved.
{ "firstName": "Mark", "age": null }
βοΈ firstName β update it. age β clear it. lastName β not sent, don't touch it.
Every PATCH endpoint needs this. Nullable DTOs can't do it. Patchly can.
dotnet add package Patchly
β‘ 30-Second Overview
1. Define your patch DTO:
[PatchDocument]
public partial class CustomerPatch
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
public int? Age { get; set; }
}
2. Use it in your endpoint:
[HttpPatch("{id}")]
public IActionResult Patch(int id, CustomerPatch patch)
{
var customer = _repo.Get(id);
if (patch.Provided.FirstName)
customer.FirstName = patch.FirstName;
if (patch.Provided.Age)
customer.Age = patch.Age; // could be null β that's intentional
_repo.Save(customer);
return Ok(customer);
}
3. Register in your web app (for OpenAPI & minimal APIs):
builder.Services.AddPatchly();
4. That's it. No middleware. No reflection. It just works.
π€ Why Does This Exist?
Every .NET developer building PATCH endpoints hits the same wall: how do you tell the difference between "the client sent null" and "the client didn't send this field at all"?
The existing options all suck:
| Approach | What's wrong with it |
|---|---|
| π« Nullable DTOs | Can't distinguish null from absent β was that null intentional or just a default? |
| π« JsonPatchDocument | Awkward operation-array format ([{ "op": "replace", "path": "/name" }]) β terrible generated clients |
| π« OData Delta<T> | Pulls in the entire OData stack for one feature |
| π« Wrapper types | Patchable<T>, JsonMergePatchDocument<T> β leak into your OpenAPI schema, ugly clients |
Patchly gives you null vs absent tracking with a clean OpenAPI schema and zero ceremony.
β What You Get
| Feature | |
|---|---|
| π Null vs absent | Distinguishes "set to null" from "not provided" |
| π Clean OpenAPI | No wrapper types in the schema β NSwag, Kiota, etc. just work |
| βοΈ System.Text.Json native | Respects [JsonPropertyName], [JsonIgnore], naming policies, and more |
| ποΈ Source-generated | AOT and trimming friendly β no runtime reflection |
| πͺ Zero ceremony | Just add [PatchDocument] to a partial class |
| ποΈ Nested tracking | [PatchDocument] properties track independently per level |
| π‘οΈ Compile-time diagnostics | Catches mistakes before you run |
π How It Compares
| Null vs absent | Clean OpenAPI | System.Text.Json | No heavy deps | Source-generated | Native AOT | |
|---|---|---|---|---|---|---|
| π©Ή Patchly | β | β | β | β | β | β (.NET 8+) |
| JsonPatchDocument | β | β | .NET 10+ only | β | β | β |
| OData Delta<T> | β | β | β | β | β | β |
| JsonMergePatch | β | β | Separate pkg | β | β | β |
| Nullable DTO | β | β | β | β | N/A | β |
π¦ Installation
dotnet add package Patchly
Requirements: .NET 6+ Β· C# 9+ (C# 12+ for required keyword)
Patchly uses System.Text.Json, which ships in-box with .NET 6+. No additional dependencies.
π§ How It Works Under the Hood
Patchly source-generates three things for each [PatchDocument] class:
- π A
JsonConverterthat tracks which properties were present in the JSON during deserialization - β
A
Providedaccessor with per-property booleans (patch.Provided.FirstName) - π A
WasProvided(string)method for generic/dynamic scenarios
When deterministic mode is enabled, Patchly also generates a State accessor and GetState(string) for explicit tri-state semantics (Omitted, Null, Value).
π‘ On the Wire
The client sends plain JSON β only the fields it wants to change:
{ "firstName": "Mark", "age": null }
firstNameβ updated to"Mark"ageβ explicitly set tonulllastNameβ not sent, left unchanged
π₯οΈ In Your Endpoint
Works with controllers:
[HttpPatch("{id}")]
public IActionResult Patch(int id, CustomerPatch patch)
{
var customer = _repo.Get(id);
if (patch.Provided.FirstName)
customer.FirstName = patch.FirstName;
if (patch.Provided.Age)
customer.Age = patch.Age; // could be null β that's intentional
_repo.Save(customer);
return Ok(customer);
}
And minimal APIs:
builder.Services.AddPatchly();
// ...
app.MapPatch("/customers/{id}", (int id, CustomerPatch patch) =>
{
var customer = repo.Get(id);
if (patch.Provided.FirstName)
customer.FirstName = patch.FirstName;
if (patch.Provided.Age)
customer.Age = patch.Age;
repo.Save(customer);
return Results.Ok(customer);
});
π‘ The
Providedaccessor is IntelliSense-discoverable β no magic strings. For generic/dynamic scenarios,WasProvided(string)andProvidedPropertiesare also available via theIPatchDocumentinterface.
π OpenAPI Support
Call AddPatchly() at startup to get correct OpenAPI schemas for your patch types:
builder.Services.AddPatchly();
builder.Services.AddOpenApi();
This inserts the Patchly type info resolver into the JSON serialization pipeline, giving the OpenAPI schema generator full visibility into your patch document properties:
{
"CustomerPatch": {
"type": "object",
"properties": {
"firstName": { "type": "string", "nullable": true },
"lastName": { "type": "string", "nullable": true },
"age": { "type": "integer", "format": "int32", "nullable": true }
}
}
}
NSwag, Kiota, and other generators produce a clean, idiomatic client.
Without
AddPatchly(), the[JsonConverter]attribute on generated classes causesJsonSchemaExporterto produce empty schemas ({ }). This is a known .NET limitation β types with custom converters cannot describe their schema to the exporter. The converter still works for deserialization β only the schema is affected.MVC controllers use separate JSON options. To configure the resolver for MVC:
builder.Services.Configure<Microsoft.AspNetCore.Mvc.JsonOptions>(o => o.JsonSerializerOptions.TypeInfoResolverChain.Insert(0, PatchlyJsonTypeInfoResolver.Default));Buffered-path types (init-only properties or
[JsonConstructor]) produce empty OpenAPI schemas. This is the same .NET limitation β these types use a converter-based resolver path that cannot be introspected byJsonSchemaExporter.Do not add Patchly converters directly to
options.Converterswhen the resolver is active β this would override the resolver and produce empty schemas.
π² Client-Side Behaviour
How the client sends partial updates depends on which client generator you use.
Kiota (recommended)
Kiota's backing store tracks which properties your code actually sets and only serializes those β including explicit nulls. Works perfectly with Patchly out of the box:
var patch = new CustomerPatch();
patch.FirstName = "Mark";
patch.Age = null; // explicitly clear age
await client.Customers[id].PatchAsync(patch);
// Sends: { "firstName": "Mark", "age": null }
// lastName is NOT sent β Kiota knows it was never touched
NSwag
NSwag generates plain DTOs with no change tracking, so by default System.Text.Json serializes all properties. Configure your serializer to skip nulls:
var options = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
This covers most cases. The trade-off is you can't explicitly send null to clear a field. For that edge case, construct the request manually:
var body = new JsonObject
{
["firstName"] = "Mark",
["age"] = null
};
ποΈ Nested Patch Documents
When a property's type is itself a [PatchDocument], tracking works independently at each level:
[PatchDocument]
public partial class AddressPatch
{
public string? Line1 { get; set; }
public string? City { get; set; }
}
[PatchDocument]
public partial class CustomerPatch
{
public string? FirstName { get; set; }
public AddressPatch? Address { get; set; }
}
{ "address": { "line1": "123 Main St" } }
patch.Provided.Address // β
true β address object was sent
patch.Address.Provided.Line1 // β
true β line1 was sent within address
patch.Address.Provided.City // β false β city was not sent
patch.Provided.FirstName // β false β not sent at all
π― Deterministic Semantics Mode
Patchly's default behavior already lets you distinguish omitted fields using Provided/WasProvided. Deterministic mode adds an explicit tri-state API for clearer handler logic.
[PatchDocument(SemanticsMode = PatchSemanticsMode.DeterministicV1)]
public partial class CustomerPatch
{
public string? FirstName { get; set; }
public List<string>? Tags { get; set; }
}
switch (patch.State.FirstName)
{
case PatchValueState.Omitted: break; // leave unchanged
case PatchValueState.Null: customer.FirstName = null; break; // clear
case PatchValueState.Value: customer.FirstName = patch.FirstName; break; // set
}
Deterministic V1 collection behavior is replace-based:
- Omitted collection field β no change
nullβ clear[]or non-empty array β replace with payload value
πΊοΈ Patch Mapping
For projects with many patch endpoints, Patchly provides a structured mapping pattern that centralizes patch-to-entity logic with DI integration.
1. Define a map:
public class CustomerPatchMap : PatchMap<CustomerPatch, Customer>
{
public override void Apply(CustomerPatch patch, Customer target)
{
if (patch.Provided.FirstName) target.GivenName = patch.FirstName;
if (patch.Provided.Age) target.Age = patch.Age ?? 0;
}
}
2. Register all maps at startup:
builder.Services.AddPatchlyMaps();
3. Inject IPatchApplier in your endpoints:
[HttpPatch("{id}")]
public IActionResult Patch(int id, CustomerPatch patch, [FromServices] IPatchApplier patchApplier)
{
var customer = _repo.Get(id);
patchApplier.Apply(patch, customer);
_repo.Save(customer);
return Ok(customer);
}
The source generator discovers all PatchMap<,> subclasses and generates:
PatchApplierβ theIPatchApplierimplementation that dispatches to the correct mapAddPatchlyMaps()β registers all maps and the applier with DI
Maps can take constructor dependencies (loggers, services, etc.) since they're resolved from the container. One map per (TPatch, TTarget) pair β the generator emits a compile error (PATCH020) if duplicates are found.
π Native AOT Support
Patchly works with Native AOT (PublishAot=true) on .NET 8+.
For web apps, AddPatchly() handles everything:
builder.Services.AddPatchly();
This inserts PatchlyJsonTypeInfoResolver at position 0 in the minimal API JSON options resolver chain.
For non-web apps or manual configuration, add the resolver to your options before your JsonSerializerContext:
var options = new JsonSerializerOptions
{
TypeInfoResolver = JsonTypeInfoResolver.Combine(
PatchlyJsonTypeInfoResolver.Default,
AppJsonContext.Default)
};
Important: The Patchly resolver handles [PatchDocument] types only. Property-level types (e.g., string, int?, List<string>) must be covered by your JsonSerializerContext. Non-AOT apps don't need any of this β the existing [JsonConverter] attribute continues to work automatically.
ποΈ Supported JSON Attributes
Patchly respects standard System.Text.Json attributes on your properties:
| Attribute | Effect |
|---|---|
[JsonPropertyName("name")] |
Overrides the JSON property name used during deserialization |
[JsonIgnore] |
Excludes the property from serialization, deserialization, and tracking |
[JsonInclude] |
Includes non-public properties in serialization and tracking |
[JsonNumberHandling(...)] |
Applies per-property number handling (e.g., AllowReadingFromString) |
[JsonConstructor] |
Uses the annotated constructor for deserialization β parameters matched to properties by name |
required keyword |
Supported β the generated converter applies [SetsRequiredMembers] internally |
init accessor |
Supported β works the same as set properties for tracking and Provided |
The generated converter also works with all JsonSerializerOptions configuration: naming policies, PropertyNameCaseInsensitive, DefaultIgnoreCondition, and JsonSerializerDefaults.Web.
init-only properties and [JsonConstructor] constructors can be mixed freely. A class can have a [JsonConstructor] without a parameterless constructor. Records remain unsupported (PATCH003).
π IPatchDocument Interface
All generated patch documents implement IPatchDocument, which exposes:
bool WasProvided(string propertyName)β check by C# property name (case-insensitive)PatchValueState GetState(string propertyName)β tri-state semantics (Omitted,Null,Value)IReadOnlySet<string> ProvidedPropertiesβ the set of C# property names that were present in the JSON
Use IPatchDocument for generic constraints:
public void ApplyPatch<T>(T patch, Action<T> apply) where T : IPatchDocument
{
if (patch.ProvidedProperties.Any())
apply(patch);
}
π‘οΈ Diagnostics
The source generator catches problems at compile time so you don't have to debug them at runtime. See Diagnostics Reference for detailed explanations and rationale.
Errors
| Code | Description |
|---|---|
| PATCH001 | Class must be declared as partial |
| PATCH002 | [PatchDocument] cannot be applied to structs |
| PATCH003 | [PatchDocument] is not supported on record types |
| PATCH004 | [PatchDocument] cannot be applied to abstract classes |
| PATCH005 | [PatchDocument] does not support generic type parameters |
| PATCH006 | Class must have an accessible parameterless or [JsonConstructor]-annotated constructor |
| PATCH014 | [JsonExtensionData] is not supported |
| PATCH018 | Multiple [JsonConstructor] constructors found |
| PATCH019 | init-only property not covered by [JsonConstructor] parameter |
| PATCH020 | Duplicate PatchMap for the same (TPatch, TTarget) pair |
| PATCH021 | [JsonConstructor] parameter type does not match property type |
| PATCH022 | [JsonConstructor] missing [SetsRequiredMembers] on class with required members |
Warnings
| Code | Description |
|---|---|
| PATCH010 | Non-nullable value type property cannot distinguish "not provided" from "default value" |
| PATCH011 | Patch document has no public properties to track |
| PATCH012 | Read-only property will be excluded from deserialization and tracking |
| PATCH017 | [JsonConstructor] parameter does not match any tracked property |
| PATCH030 | Non-nullable collection property in deterministic mode can make clear-vs-replace intent ambiguous |
Info
| Code | Description |
|---|---|
| PATCH016 | Info: init properties or [JsonConstructor] detected β alternate codegen path used |
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net6.0
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.