Patchly 0.3.0

There is a newer version of this package available.
See the version list below for details.
The owner has unlisted this package. This could mean that the package is deprecated, has security vulnerabilities or shouldn't be used anymore.
dotnet add package Patchly --version 0.3.0
                    
NuGet\Install-Package Patchly -Version 0.3.0
                    
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.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Patchly" Version="0.3.0" />
                    
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.3.0
                    
#r "nuget: Patchly, 0.3.0"
                    
#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.3.0
                    
#: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.3.0
                    
Install as a Cake Addin
#tool nuget:?package=Patchly&version=0.3.0
                    
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. 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:

  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:

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 Provided accessor is IntelliSense-discoverable β€” no magic strings. For generic/dynamic scenarios, WasProvided(string) and ProvidedProperties are also available via the IPatchDocument interface.

πŸ“‹ 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 causes JsonSchemaExporter to produce empty schemas ({ }). 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 a known limitation β€” these types use a converter-based resolver path that cannot be introspected by JsonSchemaExporter.

Do not add Patchly converters directly to options.Converters when 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'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+.

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)
  • 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

Info

Code Description
PATCH016 Info: init properties or [JsonConstructor] detected β€” alternate codegen path used
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