Patchly 0.0.2
See the version list below for details.
dotnet add package Patchly --version 0.0.2
NuGet\Install-Package Patchly -Version 0.0.2
<PackageReference Include="Patchly" Version="0.0.2" />
<PackageVersion Include="Patchly" Version="0.0.2" />
<PackageReference Include="Patchly" />
paket add Patchly --version 0.0.2
#r "nuget: Patchly, 0.0.2"
#:package Patchly@0.0.2
#addin nuget:?package=Patchly&version=0.0.2
#tool nuget:?package=Patchly&version=0.0.2
๐ฉน 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. That's it. No configuration. 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
๐ก 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 โ no configuration required:
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.
๐ In Your OpenAPI Schema
Just a plain object with nullable properties โ no wrapper types, no special formats:
{
"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.
๐ฒ 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
๐ Native AOT Support
Patchly works with Native AOT (PublishAot=true) on .NET 8+. Add PatchlyJsonTypeInfoResolver to your resolver chain before your JsonSerializerContext:
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, PatchlyJsonTypeInfoResolver.Default);
});
Or with manual JsonSerializerOptions:
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) |
required keyword |
Supported โ the generated converter applies [SetsRequiredMembers] internally |
The generated converter also works with all JsonSerializerOptions configuration: naming policies, PropertyNameCaseInsensitive, DefaultIgnoreCondition, and JsonSerializerDefaults.Web.
๐ IPatchDocument Interface
All generated patch documents implement IPatchDocument, which exposes:
bool WasProvided(string propertyName)โ check by C# property name (case-insensitive)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.
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 constructor |
| PATCH013 | init-only properties are not supported |
| PATCH014 | [JsonExtensionData] is not supported |
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 |
| PATCH015 | [JsonConstructor] is ignored by the generated converter |
๐ License
MIT
| 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
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.