Tolerant JSON contracts: surviving an inconsistent API
When the provider sends a number as a string, an object where an array was documented, and an empty string for a date — how to absorb the shape without absorbing the ambiguity.
When the provider sends a number as a string, an object where an array was documented, and an empty string for a date — how to absorb the shape without absorbing the ambiguity.
There is a specific kind of production incident that only happens with third-party integrations. Nothing in your code changed. No deployment went out. And at 02:40 the sync stops, with a deserialization exception on a field that has worked for eight months.
The provider changed something. Possibly by accident. Possibly they consider it a non-breaking change, because to them it is.
Theirs, and no.
It is worth being clear-eyed about this: you have no leverage over an external API’s payload discipline, and a rigid deserializer converts every one of their small inconsistencies into your outage. The question is not who was wrong but where you absorb the variation.
The classical answer is Postel’s law — be conservative in what you send, liberal in what you accept. It has aged badly as general advice, because unbounded leniency hides bugs and lets divergent implementations calcify. But it holds in a narrow, disciplined form:
Be liberal about the shape of what you accept. Be strict about its meaning. And record every tolerance you exercise.
The third clause is what separates this from sloppiness.
More than most people reach for a converter to solve. Before writing custom code, exhaust the options:
// Cache this. JsonSerializerOptions is frozen on first use, and constructing
// a new one per call throws away the serializer's metadata cache — this is a
// measurable performance bug, not a style preference.
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString
| JsonNumberHandling.AllowNamedFloatingPointLiterals,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
AllowReadingFromString alone kills the single most common failure — "quantity": "12" where the docs promised 12. It costs one line and no custom code.
| Symptom in the payload | Mechanism | The trap |
|---|---|---|
"12" where a number was documented |
JsonNumberHandling.AllowReadingFromString |
None — use the built-in |
| A single object where an array was documented | Single-or-array converter | Do not silently drop the case where it is neither |
"" for a date |
Lenient DateTimeOffset converter → null |
Only if the target is nullable; never DateTime.MinValue |
| An unknown enum member | Enum converter with an explicit Unknown |
Never map the unknown onto a valid member |
"1" / "Y" / "true" for a boolean |
Lenient bool converter | Enumerate the accepted values; do not treat “anything non-empty” as true |
| A field that vanishes entirely | Nullable property + required on the ones that truly matter |
Distinguish “absent” from “zero” |
The single-or-array converter is the one you will write most often, because it comes from a very common server-side pattern — serializing a collection that happens to contain one element as a bare object:
public sealed class SingleOrArrayConverter<T> : JsonConverter<T[]>
{
// Without this, System.Text.Json short-circuits a null token and never
// calls Read at all — the Null branch below would be dead code.
public override bool HandleNull => true;
public override T[] Read(ref Utf8JsonReader reader, Type _, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.Null:
return [];
case JsonTokenType.StartObject:
return [JsonSerializer.Deserialize<T>(ref reader, options)!];
case JsonTokenType.StartArray:
// Read the elements yourself. Calling Deserialize<T[]>(ref reader, options)
// here re-enters THIS converter — an unbounded recursion that ends in a
// StackOverflowException, which .NET will not let you catch.
var items = new List<T>();
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
items.Add(JsonSerializer.Deserialize<T>(ref reader, options)!);
return items.ToArray();
default:
// Anything else is genuinely unexpected — let it throw, and let the
// exception name the type. Do NOT return an empty array here.
throw new JsonException($"Unexpected token {reader.TokenType} for {typeof(T).Name}");
}
}
}
Two things in there are worth more than they look.
The recursion comment is not hypothetical. The obvious-looking JsonSerializer.Deserialize<T[]>(ref reader, options) for the array case reads as “the normal path, handled normally” — but options still contains this converter, so it calls itself. The failure is a stack overflow, which terminates the process without an exception you can log. It only triggers on the array input, which is the common shape, so it will get past a test that only covers the interesting single-object case.
The default branch is the discipline. It would be easy to return [] there and have the integration “never crash”. That is exactly how a silent data-loss bug is born: a payload arrives in a shape you have never seen, you record zero items, and the reconciliation report is wrong for a month before anyone notices.
Providers add enum values without considering it a breaking change. A new courier status, a new order state, a new document type — from their side it is additive.
JsonStringEnumConverter will throw on an unrecognised value, which takes down the whole record. The instinct is to map the unknown onto a safe default. Resist it:
public enum OrderStatus
{
Unknown = 0, // reserved for values we have not seen before
New = 1,
Confirmed = 2,
Cancelled = 3,
}
Unknown must be a member that your business logic explicitly refuses to act on — it should route the record to a review queue, not into the happy path. If you map an unrecognised status onto New, you have not made the system resilient, you have made it confidently wrong. And note the direction of danger: mapping an unknown onto Cancelled would be worse still.
This is the line that decides whether tolerant parsing is engineering or negligence.
Acceptable: a price that arrives as "14.90" instead of 14.90. Same value, different encoding — the meaning is unambiguous.
Not acceptable: a price that is missing, and you default it to 0. Zero is a real price. You have manufactured a fact.
The test I apply: could a reasonable person read the tolerated value differently from the raw one? If yes, it is not a shape fix, it is an invention. Missing money, missing quantities, missing identifiers and missing timestamps all belong in the second category — they should surface as null and force a decision, or fail loudly.
C# 11’s required members are the enforcement mechanism for the fields where absence is genuinely fatal:
public sealed class OrderDto
{
public required string Id { get; init; } // absent → fail, immediately
public required decimal Total { get; init; }
public DateTimeOffset? ShippedAt { get; init; } // legitimately absent
public OrderStatus Status { get; init; }
}
The DTO is allowed to be permissive, nullable and unpleasant. Your domain model is not.
Two types, one explicit mapping step between them. The mapping is where you decide what an absent value means in your business, and it is a far better place for that decision than a converter, because it has context a converter never will.
This also gives you a clean seam for validation. A DTO that deserialized successfully is not the same as a payload that made sense — an order with a Total that does not match the sum of its lines parses perfectly.
This is the part that is usually missing, and without it the whole approach backfires. If your converters absorb everything silently, you have built a system that cannot tell you when the provider changed.
Two mechanisms, both cheap:
JsonSerializerOptions.UnmappedMemberHandling can be set to Disallow to throw on unknown properties — usually too aggressive for a live integration, but excellent in a test suite run against recorded payloads. In production, prefer an [JsonExtensionData] dictionary that you log the keys of.Then keep golden files: real responses, saved verbatim, replayed in tests. When the provider sends a new shape, you want to find out from a failing CI run against a freshly captured sample, not from a support ticket. On integrations where the provider’s output drifted regularly, the golden-file suite caught more real problems than any other test category.
JsonElement or dynamic. You have not solved the problem, you have moved it to the call site — where it is now scattered across dozens of places instead of one converter, and the compiler helps you with none of it.try/catch and return null. This discards the entire record because one field was odd, and destroys the diagnostic — you know something failed, but not what.The underlying principle is the one that carried over from a very different domain — telephony interop, where the other end implements the same protocol you do and still gets it subtly wrong. You do not get to fix the other implementation. You get to be precise about which deviations you absorb, and to keep a record of every one you did.
If you are building an integration against an API that keeps surprising you and want a second opinion on where to put the tolerance, that is a conversation I am glad to have.
Atos IT Solutions and Services
Software Engineer
Oct 2019 – Feb 2022
If you are building in this space, let us talk for 30 minutes.
Book a call