JSON Patch with System.Text.Json (.NET 10)

Accept application/json-patch+json with the System.Text.Json JsonPatchDocument<T>, apply it safely to a DTO, validate the result, or pick merge patch instead.

Syntax[HttpPatch("{id}")] public IActionResult Patch(int id, [FromBody] JsonPatchDocument<OrderPatch> patch) { patch.ApplyTo(dto, e => ModelState.AddModelError(e.Operation?.path ?? "", e.ErrorMessage)); }

JSON Patch (RFC 6902) is a list of operations against a JSON document, sent with the media type application/json-patch+json:

[
  { "op": "test",    "path": "/status", "value": "Pending" },
  { "op": "replace", "path": "/city",   "value": "York" },
  { "op": "add",     "path": "/notes/-", "value": "Leave at the door" }
]

Operations are add, remove, replace, move, copy and test; paths are JSON Pointers and /- appends to an array. test makes the patch conditional, which gives you a compare-and-set on a single field.

The System.Text.Json implementation

In .NET 10 JSON Patch is available on System.Text.Json through the Microsoft.AspNetCore.JsonPatch.SystemTextJson package (namespace of the same name). Its JsonPatchDocument<T> deserialises with System.Text.Json, so it binds as a [FromBody] parameter in controllers and as a body parameter in minimal APIs, with no Newtonsoft.Json and no AddNewtonsoftJson(). The older Microsoft.AspNetCore.JsonPatch package depended on Newtonsoft.Json; new code should use the System.Text.Json one.

The request must carry Content-Type: application/json-patch+json; plain application/json is rejected with 415. Paths match property names case-insensitively, so /city and /City both work.

ApplyTo is not atomic

patch.ApplyTo(target) applies operations in order and throws JsonPatchException at the first failure, leaving the earlier ones applied. patch.ApplyTo(target, error => ...) reports each failure to your callback and carries on with the rest. Either way a half-applied object exists, so the safe sequence is:

  1. Load the entity and copy the patchable fields into a patch DTO.
  2. Apply the patch to the DTO, collecting errors (unknown path, bad index, failed test, wrong value type).
  3. If there were errors, return them as a validation problem.
  4. Validate the DTO (TryValidateModel in a controller). A patch can remove a required field or replace it with an empty string, and model validation never ran on the patch document itself.
  5. Map the DTO onto the entity and save.

Security

A patch may target any public settable property of T. Patching an entity lets a client replace /creditLimit; patching a DTO limits it to what you exposed. Limiting the fields does not replace checking that the caller owns the resource: load it, compare its owner with the caller's claim, and answer 404 on a mismatch. Also cap the operation count and array growth: a body of 50,000 add operations is cheap to send and expensive to apply.

When merge patch is simpler

ApproachBodyBest for
PUTThe full resourceSmall resources, simple clients
JSON Merge Patch (RFC 7396)The changed fields; null removesForms and most partial updates
JSON Patch (RFC 6902)An operation listArray edits by index, conditional updates, audit of exact operations

Most front ends naturally produce merge patches. Reach for JSON Patch when you need test or precise array operations.

Example

Example · csharp
using System.Collections.Concurrent;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.JsonPatch.SystemTextJson;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddAuthentication().AddJwtBearer(); // settings from Authentication:Schemes:Bearer
builder.Services.AddAuthorization();
builder.Services.AddControllers();
builder.Services.AddSingleton<OrderRepository>();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

public sealed class Order
{
    public int Id { get; init; }
    public string CustomerId { get; init; } = "";
    public decimal Total { get; set; }
    public string Status { get; set; } = "Pending";
    public string Street { get; set; } = "";
    public string City { get; set; } = "";
    public string Postcode { get; set; } = "";
    public List<string> DeliveryNotes { get; set; } = [];
}

// What a client may patch. Total and CustomerId are deliberately absent.
public sealed class ShippingPatch
{
    [Required, StringLength(100, MinimumLength = 3)] public string Street { get; set; } = "";
    [Required, StringLength(60, MinimumLength = 2)] public string City { get; set; } = "";
    [Required, RegularExpression("^[A-Z0-9 ]{5,8}$")] public string Postcode { get; set; } = "";
    [MaxLength(5)] public List<string> DeliveryNotes { get; set; } = [];
    public string Status { get; set; } = ""; // readable for "test", rejected if changed
}

public sealed class OrderRepository
{
    private readonly ConcurrentDictionary<int, Order> _orders = new()
    {
        [1001] = new Order { Id = 1001, CustomerId = "CUST-1042", Total = 79.90m,
            Street = "1 Mill Lane", City = "Leeds", Postcode = "LS1 4AP" },
    };

    public Order? Find(int id) => _orders.GetValueOrDefault(id);
}

[ApiController]
[Authorize]
[Route("api/orders")]
public sealed class OrderShippingController(OrderRepository orders) : ControllerBase
{
    private const int MaxOperations = 20;

    // PATCH /api/orders/1001/shipping
    // Content-Type: application/json-patch+json
    [HttpPatch("{id:int}/shipping")]
    [Consumes("application/json-patch+json")]
    public IActionResult PatchShipping(int id, [FromBody] JsonPatchDocument<ShippingPatch> patch)
    {
        if (patch.Operations.Count > MaxOperations)
            return Problem(statusCode: StatusCodes.Status400BadRequest,
                title: $"A patch may contain at most {MaxOperations} operations.");

        // Another customer's order is a 404, not a 403, so ids cannot be probed.
        if (orders.Find(id) is not { } order || order.CustomerId != User.FindFirstValue("customer_id"))
            return NotFound();
        if (order.Status is "Shipped" or "Delivered")
            return Problem(statusCode: StatusCodes.Status409Conflict, title: "The order has already shipped.");

        // 1. Copy into the DTO, never patch the entity directly.
        var dto = new ShippingPatch
        {
            Street = order.Street, City = order.City, Postcode = order.Postcode,
            DeliveryNotes = [.. order.DeliveryNotes], Status = order.Status
        };

        // 2. Apply, collecting every failed operation (unknown path, failed test, bad type).
        patch.ApplyTo(dto, error =>
            ModelState.AddModelError(error.Operation?.path ?? "patch", error.ErrorMessage));
        if (!ModelState.IsValid) return ValidationProblem(ModelState);

        // 3. Validate the patched result: "remove /city" or "replace /postcode" with junk lands here.
        if (dto.Status != order.Status)
            ModelState.AddModelError("/status", "Status cannot be changed through this endpoint.");
        if (!TryValidateModel(dto) || !ModelState.IsValid) return ValidationProblem(ModelState);

        // 4. Only now touch the entity.
        order.Street = dto.Street;
        order.City = dto.City;
        order.Postcode = dto.Postcode;
        order.DeliveryNotes = dto.DeliveryNotes;
        return Ok(dto);
    }
}

When to use it

  • Let customers change the shipping address on a pending order with a test op on status, so a shipped parcel is never redirected.
  • Append a delivery note with an add to /deliveryNotes/- without resending the whole order.
  • Expose PATCH /api/products/{sku} as a merge patch for an admin form that only sends the fields the user edited.

More examples

The same endpoint as a minimal API

Minimal APIs bind <code>JsonPatchDocument&lt;T&gt;</code> from a body sent as <code>application/json-patch+json</code> and reject <code>application/json</code> with 415, exactly like controllers. There is no <code>ModelState</code>, so errors go into a dictionary that <code>Results.ValidationProblem</code> turns into the standard 400 body. The stored object is replaced only after the copy passes both the patch and validation.

Example · csharp
using System.Collections.Concurrent;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.JsonPatch.SystemTextJson;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();

var preferences = new ConcurrentDictionary<string, CustomerPreferences>
{
    ["CUST-1042"] = new() { Language = "en-GB", MarketingEmails = false, Channels = ["email"] },
};

app.MapPatch("/api/customers/{id}/preferences", (string id, JsonPatchDocument<CustomerPreferences> patch) =>
{
    if (!preferences.TryGetValue(id, out var current)) return Results.NotFound();

    var copy = new CustomerPreferences
    {
        Language = current.Language, MarketingEmails = current.MarketingEmails, Channels = [.. current.Channels]
    };

    var errors = new Dictionary<string, string[]>();
    patch.ApplyTo(copy, e => errors[e.Operation?.path ?? "patch"] = [e.ErrorMessage]);

    var results = new List<ValidationResult>();
    if (!Validator.TryValidateObject(copy, new ValidationContext(copy), results, validateAllProperties: true))
        foreach (var r in results)
            errors[r.MemberNames.FirstOrDefault() ?? "patch"] = [r.ErrorMessage ?? "Invalid value."];

    if (errors.Count > 0) return Results.ValidationProblem(errors);

    preferences[id] = copy;
    return Results.Ok(copy);
});

app.Run();

public sealed class CustomerPreferences
{
    [Required, RegularExpression("^[a-z]{2}-[A-Z]{2}$")] public string Language { get; set; } = "en-GB";
    public bool MarketingEmails { get; set; }
    [MaxLength(3)] public List<string> Channels { get; set; } = [];
}

Building and sending a patch from a .NET client

The typed builder turns expressions into paths such as <code>/City</code> and <code>/DeliveryNotes/-</code>; the server matches them case-insensitively, so they work against camelCase APIs. The leading <code>test</code> operation makes the change conditional: if a warehouse worker already moved the order to Packed, the patch fails with a validation error instead of rewriting the address of a parcel that is already on a lorry.

Example · csharp
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.AspNetCore.JsonPatch.SystemTextJson;

using var http = new HttpClient { BaseAddress = new Uri("https://orders.internal.example.com") };

var patch = new JsonPatchDocument<ShippingPatch>()
    .Test(p => p.Status, "Pending")          // fail if the order moved on meanwhile
    .Replace(p => p.City, "York")
    .Replace(p => p.Postcode, "YO1 7HH")
    .Add(p => p.DeliveryNotes, "Leave with reception");

var content = new StringContent(JsonSerializer.Serialize(patch));
content.Headers.ContentType = new MediaTypeHeaderValue("application/json-patch+json");

using var response = await http.PatchAsync("/api/orders/1001/shipping", content);
Console.WriteLine($"{(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}");

public sealed class ShippingPatch
{
    public string Street { get; set; } = "";
    public string City { get; set; } = "";
    public string Postcode { get; set; } = "";
    public List<string> DeliveryNotes { get; set; } = [];
    public string Status { get; set; } = "";
}

Merge patch with JsonObject when JSON Patch is overkill

Merge patch needs to tell an absent field from an explicit <code>null</code>, which a typed DTO cannot do because both arrive as <code>null</code>. <code>JsonObject</code> keeps that difference: <code>TryGetPropertyValue</code> returns false for an absent key and true with a <code>null</code> node for an explicit null. The allow-list blocks clients from patching fields such as <code>sku</code>. Values are read with <code>JsonValue.TryGetValue&lt;T&gt;</code>, so <code>{"price": "cheap"}</code> becomes a 400 validation problem; <code>GetValue&lt;T&gt;</code> throws <code>InvalidOperationException</code> for a wrongly typed node, which would surface as a 500.

Example · csharp
using System.Text.Json.Nodes;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();

var product = new Product("KETTLE-BLUE", "Blue kettle", 39.95m, "Fast-boil 1.7 l kettle");

// PATCH /api/products/KETTLE-BLUE  {"price": 34.95, "description": null}
app.MapPatch("/api/products/{sku}", (string sku, JsonObject changes) =>
{
    if (sku != product.Sku) return Results.NotFound();

    var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "name", "price", "description" };
    var unknown = changes.Select(p => p.Key).Where(k => !allowed.Contains(k)).ToArray();
    if (unknown.Length > 0)
        return Results.ValidationProblem(unknown.ToDictionary(k => k, _ => new[] { "This field cannot be patched." }));

    // Absent keys are left alone; a key set to null clears the value. TryGetValue turns a
    // wrongly typed value into a 400; GetValue would throw InvalidOperationException (a 500).
    var errors = new Dictionary<string, string[]>();
    var name = product.Name;
    var price = product.Price;
    var description = product.Description;

    if (changes.TryGetPropertyValue("name", out var nameNode))
    {
        if (nameNode is JsonValue v && v.TryGetValue<string>(out var s) && s.Length > 0) name = s;
        else errors["name"] = ["Must be a non-empty string."];
    }
    if (changes.TryGetPropertyValue("price", out var priceNode))
    {
        if (priceNode is JsonValue v && v.TryGetValue<decimal>(out var p) && p > 0) price = p;
        else errors["price"] = ["Must be a positive number."];
    }
    if (changes.TryGetPropertyValue("description", out var descriptionNode))
    {
        if (descriptionNode is null) description = null;
        else if (descriptionNode is JsonValue v && v.TryGetValue<string>(out var d)) description = d;
        else errors["description"] = ["Must be a string or null."];
    }
    if (errors.Count > 0) return Results.ValidationProblem(errors);

    product = product with { Name = name, Price = price, Description = description };
    return Results.Ok(product);
});

app.Run();

public record Product(string Sku, string Name, decimal Price, string? Description);

Discussion

  • Be the first to comment on this lesson.