Every backend eventually hits the same wall: clients need to update part of a resource, but sending the whole object back and forth feels wrong.
We were building PATCH endpoints for complex domain entities with DTOs that had 20+ fields, nested objects, and a couple of lists. The frontend was building forms, and most edits were small: update a description, flip a boolean, adjust a timestamp.
Asking the SPA to send the full DTO just to change one field felt wastefull and risky. One stale field could overwrite newer server state. It also felt like the backend was outsourcing correctness to the client.
We already had a homegrown pattern (TrackableObject<T>) where the client could signal which fields were intentionally sent. It worked, but it was custom, hard to explain to newcomers, and it didn't cover array operations.
So we went looking for a standard.
Enter JSON Patch (RFC 6902)
JSON Patch is an IETF standard: you send an array of operations like replace, add, remove, etc., each targeting a location using JSON Pointer paths.
[
{ "op": "replace", "path": "/description", "value": "Updated text" },
{ "op": "add", "path": "/tags/-", "value": "urgent" }
]
On paper, its elegant. You get surgical precision (including arrays), and even a test op that can act like an in-band "only apply if unchanged" guard.
We used a System.Text.Json-friendly JSON Patch library (to avoid pulling Newtonsoft into our gateway).
What worked well (for the backend)
The gateway-applies-patch pattern
We kept JSON Patch logic out of our microservices. The BFF/gateway was the only layer that understood patch:
- Frontend sends PATCH to the gateway
- Gateway GETs current state from the downstream service
- Gateway applies patch locally
- Gateway PUTs the full updated DTO downstream
That part was clean, and we still like the architectural boundary.
Standards compliance
Pointing people at an RFC beats explaining custom semantics every time.
Where reality hit: frontend complaints + spec-driven tooling
This is the part that surprised us: the protocol is fine, but the ecosytem around it made it painful.
1. OpenAPI can't express "path -> value type" in a helpful way
Our workflow was spec-driven:
- Backend auto-generates OpenAPI
- Frontend uses Orval to generate TypeScript clients/hooks
This pipeline is amazing when your request body is a typed object.
But JSON Patch is "an array of operations," and the meaningful bits live inside:
path: string (semantic meaning hidden in a string)value: anything (type depends on the path and operation)
So OpenAPI typically ends up describing JSON Patch as generic operations (op, path, value) where value is basically "any." That's not Orval failing - Orval is just faithfully generating what the spec can describe.
Yes, you can try to model it with oneOf/compositions, but it quickly becomes a combinatoral mess once you have dozens of fields and multiple operations.
2. Our auto-generated OpenAPI needed post-processing
We ended up adding a transformer step to "fix up" the OpenAPI around patch payloads so our client generation wouldn't degrade too badly.
That pattern is common in the wild: JSON Patch + Swagger tooling often yields unhelpful or even broken schemas, and teams work around it with custom schema filters, alternate operation models, etc.
3. The frontend experience felt like busywork
The most consistent feedback from frontend was simple: this doesn't feel like form data.
They wanted to send:
{ "description": "Updated text" }
Instead they had to construct:
[{ "op": "replace", "path": "/description", "value": "Updated text" }]
Then came the follow-up pain:
- Nested paths (JSON Pointer uses
/, escaping rules, special-index, etc.) - Nullable semantics (
removevsreplacewith null) - "What paths are valid?" (not really discoverable through generated types)
None of this is hard once you know it. The issue is the steady drip of friction across many endpoints and many sprints.
4. The extra round-trip wasn't free
Our gateway pattern required a GET before applying the patch, which turned every PATCH into "GET + apply + PUT." For small edits, it felt disproportionate.
5. Testing surface area exploded
Now you're testing ordered operation sequences, invalid paths, array bounds, and edge cases around test + subsequent ops. Compared to "send a partial typed object," the matrix grows fast.
What we moved to: typed partial DTO + "assigned properties"
We went back to the boring approach - but made it deliberate.
Frontend sends a normal typed JSON object:
{ "description": "Updated text", "isComplete": true }
Backend tracks which properties were actually present in the request (think "assigned properties") and only updates those. Missing fields remain untouched.
What we gained:
- Clean OpenAPI schemas
- Great Orval-generated clients
- Better autocomplete and compile-time safety
- Easier payloads for forms
What we lost:
- Surgical array operations
- The
testoperation
In practice, we almost never needed "insert item at index 0." Our real use cases were "replace the whole list." And concurrency checks lived elsewhere.
Lessons learned
JSON Patch is a solid standard. But if your primary consumer is a typed SPA built on OpenAPI -> codegen, JSON Patch fights your toolchain because its semantics aren't easily representable in a typed schema.
Sometimes the most "correct" protocol choice isn't the most productive choice.
Resources
- RFC 6902: JSON Patch
- RFC 6901: JSON Pointer (path syntax used by JSON Patch)
- jsonpatch.com (practical examples)
- Orval docs (OpenAPI -> type-safe TS clients)
- SystemTextJsonPatch (System.Text.Json-friendly JSON Patch)
- ASP.NET Core JSON Patch docs
- Swagger/OpenAPI composition (
oneOf/anyOf/allOf) and why it gets gnarly
