Every integration I've worked on in healthcare eventually turns into an argument about JSON. Not about the library — about the contract underneath it, and what happens when one side changes and the other doesn't.
.NET's System.Text.Json has matured to the point where the mechanics are rarely the problem. So this is about the decisions it forces you to make, most of which are contract decisions wearing serialization clothes.
Serialization Is an API Contract
The moment another system reads your JSON, your property names are public API. Rename one and you've shipped a breaking change, regardless of what your version number says.
This sounds obvious and is routinely violated, because in C# renaming a property is a safe refactor and the tooling encourages it. The type system that protects you inside the process stops at the serialization boundary.
Two habits prevent most of the pain:
Separate your wire types from your domain types. The extra mapping layer feels redundant right up to the first time you want to restructure a domain model without breaking an integration. Then it's the thing that makes it possible. A DTO exists precisely so that the shape you send and the shape you think in can evolve independently.
Make naming explicit. Set a naming policy deliberately, or annotate properties. Don't let the wire format be an emergent property of C# conventions plus whatever the default was in the framework version you started on.
Decide What Unknown Fields Mean
When a payload arrives with a property you don't recognise, you have three options and you must choose on purpose:
- Ignore it. The default, and usually right for a consumer. Tolerant readers make integrations survivable.
- Reject it. Right when an unknown field means the sender is confused and proceeding would be worse than failing. Strict validation has a place, particularly for anything financial.
- Round-trip it. Capture unknowns and preserve them on write. Essential when you sit in the middle of a pipeline — dropping a field you didn't understand can silently destroy data that mattered to somebody downstream.
That third case is the one teams forget, and it's the one that causes the incident you can't explain, because the evidence was discarded on the way through.
Polymorphism Needs a Discriminator You Own
Any payload where the shape depends on a type — different claim categories, different response kinds — needs an explicit discriminator property with explicit values you control.
The temptation is to infer type from which fields are present. This works beautifully until a new variant shares fields with an old one, and then it fails in a way that takes a day to diagnose because the deserializer produced a valid object of the wrong type. A wrong type that parses is far more expensive than a parse failure.
Declare the mapping explicitly, and make the discriminator values stable strings rather than enum ordinals or anything else that shifts when the code changes.
Nullability Is Where Contracts Lie
C#'s nullable reference types describe intent inside your process. They are not enforced at deserialization. A non-nullable string property can absolutely be null after parsing a payload that omitted it, and nothing will warn you.
This is one of the most common sources of a surprising NullReferenceException a long way from its cause. Required fields need validating at the boundary, explicitly, the moment the payload is parsed — not assumed because the type says so.
The general rule: validate at the edge, then trust inside. An object that made it past your boundary should be one your domain code can rely on. If the validation is scattered through the domain instead, every consumer re-checks and one of them eventually forgets.
Performance, and When It Matters
Source-generated serialization contexts avoid reflection, start faster, and work under trimming and AOT. For a service handling large volumes — or a container where cold-start time is a real cost — it's worth adopting.
But measure before optimising. In most services, serialization is not the bottleneck; the database is, or the network is. The exception is high-volume pipelines, where serialization genuinely shows up in the profile and the gain is real.
The infrastructure reason is often more compelling than the speed: if you're heading toward trimming or ahead-of-time compilation, reflection-based serialization is a blocker and moving to source generation is a prerequisite rather than an optimisation.
What I'd Tell a Team
Own the contract explicitly. Separate wire types from domain types. Decide deliberately what unknown fields mean and write it down. Use explicit discriminators. Validate required fields at the boundary instead of trusting nullable annotations. Measure before reaching for source generation, and adopt it anyway if trimming is in your future.
None of that is about the library. It's about treating the JSON you emit as a promise to somebody else, which is what it is.



