Two JSON serializers can each behave correctly and still fail when they meet.
That sounds obvious in hindsight, but it is easy to miss in a .NET solution. An ASP.NET Core server may use System.Text.Json by default while a long-lived typed client, shared library, or older integration still uses Newtonsoft.Json. Both libraries can serialise ordinary objects. Both can pass their own round-trip tests. The failure appears only when one writes the payload and the other reads it.
A recent committed C# fix made the distinction concrete. The private domain details are not important. The reusable lesson is: a same-serializer round trip proves self-consistency, not interoperability.
The mismatch hides at the real boundary
Imagine a response containing an abstract base type with two supported derived shapes:
[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(ImmediateRule), "immediate")]
[JsonDerivedType(typeof(WindowRule), "window")]
public abstract record DeliveryRule;
System.Text.Json understands those attributes and writes a discriminator such as "kind": "window". A Newtonsoft.Json consumer does not automatically interpret System.Text.Json's polymorphism metadata. It sees the abstract base type, cannot choose a concrete type, and fails to construct the object.
Nothing is malformed on the wire. The producer followed its contract. The consumer followed a different contract.
This is why an in-process path may work while a REST-backed path fails. The in-process path never serialises. It passes the object directly and quietly bypasses the boundary that is broken.
Treat polymorphism as a wire protocol
Once a payload carries derived types, the discriminator is not an implementation detail. It is protocol data.
The contract needs to answer a few explicit questions:
- What property identifies the concrete shape?
- Which discriminator values are supported?
- Are names case-sensitive?
- Which fields are required for each shape?
- What happens when the value is missing or unknown?
- Must the contract work in one direction or both?
Attributes on a C# type answer those questions only for libraries that understand the attributes. They do not make the protocol universal.
In the change I reviewed, the practical repair was a compatibility converter for the second serializer. It read the discriminator emitted by the server and constructed the correct derived shape. Its write path emitted the same discriminator so payloads could also travel in the reverse direction.
Cross the boundary in the test
The most valuable change was not the converter. It was the direction of the tests.
A weak test looks like this:
var wire = System.Text.Json.JsonSerializer.Serialize(value, options);
var copy = System.Text.Json.JsonSerializer.Deserialize<BaseType>(wire, options);
That is useful, but it asks one library whether it agrees with itself.
An interoperability test should model the deployed path:
var wire = System.Text.Json.JsonSerializer.Serialize(envelope, serverOptions);
var received = Newtonsoft.Json.JsonConvert.DeserializeObject<Envelope>(wire);
If the reverse direction exists, test that separately:
var wire = Newtonsoft.Json.JsonConvert.SerializeObject(value);
var received = System.Text.Json.JsonSerializer.Deserialize<BaseType>(wire, serverOptions);
Then repeat the test for every supported derived shape. A single happy subtype can hide a missing discriminator, a date-format difference, enum handling, nullability, or a field that only exists on another subtype.
Also include a negative test for an unknown discriminator. Silently defaulting to a base shape can be more dangerous than failing, because it turns contract drift into plausible but incomplete data.
The adapter has a cost
An explicit converter duplicates contract knowledge. The base type, each serializer's configuration, the converter, and the tests must evolve together. Adding a subtype is no longer a one-line change.
Standardising the whole system on one serializer can remove that duplication. It may also be a broad migration touching clients, stored payloads, casing, date handling, enum representation, reference loops, and error behaviour. A narrow adapter is often the safer repair when compatibility matters immediately.
There is another implementation trap: a converter attached to a base type can re-enter itself if it delegates deserialisation of the derived type through the same configured serializer. Depending on the library, constructing the derived record explicitly or using a converter-free inner path can avoid recursion. Pin that behaviour with a focused test rather than relying on intuition.
A practical contract-test checklist
Before calling a JSON contract covered, write down:
- The actual producer library and configuration.
- The actual consumer library and configuration.
- Every supported polymorphic shape.
- The discriminator and required fields.
- Unknown, missing, null, and malformed behaviour.
- Every direction the payload travels in production.
The unit of confidence is not "this serializer can round-trip this type." It is "this producer's bytes can be consumed by that reader without losing meaning."
Where are your contract tests still testing each endpoint in isolation instead of crossing the wire between them?
Top comments (1)
the distinction between self consistency and interoperability is the key point. i would keep a small set of versioned wire fixtures for each subtype, then run them through the real producer and consumer libraries in both directions. add an unknown discriminator case and compare the error shape too, because clients often depend on that part of the contract. this can catch drift before an integration path reaches production.