Patchly 0.0.3

There is a newer version of this package available.
See the version list below for details.
dotnet add package Patchly --version 0.0.3
                    
NuGet\Install-Package Patchly -Version 0.0.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Patchly" Version="0.0.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Patchly" Version="0.0.3" />
                    
Directory.Packages.props
<PackageReference Include="Patchly" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Patchly --version 0.0.3
                    
#r "nuget: Patchly, 0.0.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Patchly@0.0.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Patchly&version=0.0.3
                    
Install as a Cake Addin
#tool nuget:?package=Patchly&version=0.0.3
                    
Install as a Cake Tool

🩹 Patchly

CI NuGet License: MIT

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:

  1. πŸ”„ A JsonConverter that tracks which properties were present in the JSON during deserialization
  2. βœ… A Provided accessor with per-property booleans (patch.Provided.FirstName)
  3. πŸ”— 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 to null
  • lastName β†’ 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 Provided accessor is IntelliSense-discoverable β€” no magic strings. For generic/dynamic scenarios, WasProvided(string) and ProvidedProperties are also available via the IPatchDocument interface.

πŸ“‹ 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'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

πŸ—ΊοΈ 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 β€” the IPatchApplier implementation that dispatches to the correct map
  • AddPatchlyMaps() β€” 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+. 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
PATCH020 Duplicate PatchMap for the same (TPatch, TTarget) pair

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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.4.0 85 2/28/2026
0.3.3 86 2/27/2026
0.3.2 85 2/27/2026
0.3.1 88 2/23/2026
0.2.0 84 2/23/2026
0.1.0 97 2/22/2026
0.0.3 87 2/21/2026
0.0.2 93 2/21/2026
0.0.1 88 2/21/2026