- Published on
Reading a Schema You Do Not Own
- Authors

- Name
- Mathias Hove
- @mathias_hove
Take a product feed with a field called available_from. It says when a product goes on sale, and the value looks like 2026-11-20T09:00:00+01:00.
One day the source starts sending 2026-11-20T09:00:00 instead. No offset. The reader still has to turn that into a point in time, so it guesses. DateTime.Parse uses the local time of the machine, and on a server in Azure that is usually UTC. Denmark is an hour ahead of UTC in winter, so the product goes on sale at 10:00 instead of 09:00.
Nothing fails. The import runs, the counts look normal and the product is on the site. It is an hour late, and nobody notices until a customer asks.
I did not design that feed and I cannot stop it from changing. Most of the data I import is like that. Somebody else owns it, they change it for their own reasons, and they do not ask first.
The reader knew something it never said
The first reader had a rule in it: available_from has an offset. The rule was real, because the code only worked when it held. But it was never written down. It lived inside a call to DateTime.Parse, and that is exactly the kind of call that keeps working after the rule stops holding.
The fix is to write the rule down and let the reader check it on every record. I use a table with one row per field I depend on:
| Source path | Target | Transform | Required |
|---|---|---|---|
| product.id | ProductId | text | yes |
| product.available_from | AvailableFrom | instant | yes |
| product.brand | Brand | text | no |
Each row says where a value is, what to call it, how to read it, and whether a record can do without it. instant is the name of a transform, and that transform accepts a date, a time and an offset in exactly that form. Nothing else. If the offset is not there, the transform fails and the record is rejected with the name of the field. There is no transform in the table that guesses.
The code
public sealed record FieldMapping(string Path, string Target, string Transform, bool Required);
public sealed record Problem(string Path, string Detail);
public sealed record MappedRecord(
IReadOnlyDictionary<string, object> Values,
IReadOnlyList<Problem> Rejections) // required and missing, or any value that cannot be read
{
public bool Accepted => Rejections.Count == 0;
}
public static class Mapper
{
static readonly Dictionary<string, Func<JsonElement, object>> Transforms = new()
{
["text"] = e => e.GetString()!.Trim(),
["instant"] = e => DateTimeOffset.ParseExact(
e.GetString()!, "yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture),
};
public static MappedRecord Apply(JsonElement source, IReadOnlyList<FieldMapping> mappings)
{
var values = new Dictionary<string, object>();
var rejections = new List<Problem>();
foreach (var m in mappings)
{
if (!TryFind(source, m.Path, out var raw) || raw.ValueKind == JsonValueKind.Null)
{
if (m.Required) rejections.Add(new(m.Path, "missing"));
continue;
}
try { values[m.Target] = Transforms[m.Transform](raw); }
catch (Exception e) when (e is FormatException or InvalidOperationException)
{
rejections.Add(new(m.Path, e.Message));
}
}
return new(values, rejections);
}
static bool TryFind(JsonElement node, string path, out JsonElement found)
{
found = node;
foreach (var part in path.Split('.'))
{
if (found.ValueKind != JsonValueKind.Object || !found.TryGetProperty(part, out found))
return false;
}
return true;
}
}
A record is rejected when a required value is missing, or when any value is there but cannot be read. Each rejection carries the path of the field and what was wrong with it. Everything else goes through.
Run the date through it. 2026-11-20T09:00:00+01:00 is accepted. 2026-11-20T09:00:00 is rejected, and the message names product.available_from. 2026-11-20T08:00:00Z is rejected too. It probably means the same moment, but the format says zzz and not Z, and I would rather add Z to the format on purpose than have it accepted by accident.
Same field, same change upstream. One reader guessed an hour and carried on. The other stopped and said which field it could not read.
Why this is worth doing
It is cheap. A row per field, written once, and a handful of transforms that I reuse across every feed. The first reader was not less work. It was the same knowledge, kept in a place where nothing could check it.
It finds the problem at the cheapest time. A bad date caught at import is a line in a log with a field name in it, and I fix the mapping or ring the supplier before anyone else is involved. The same bad date caught a week later is in the database, on the site, in a customer's order and in somebody's inbox. Every hour between the import and the discovery makes it more expensive.
It makes the change visible. A feed that changes under a strict reader produces a rejection that names the field. A feed that changes under a lenient reader produces nothing. I have no way to tell the difference between a feed that is fine and a feed that has changed in a way the reader papered over.
It keeps the list of what I depend on true. The table is the only place that touches the feed, so it is a complete list of the fields I use and what I expect from each. It cannot go stale, because the reader runs it on every record. When the supplier announces a change, I open the table and I know in a minute whether it concerns me.
Final thought
I still catch myself writing DateTime.Parse and moving on, because it works on every example I have in front of me. These days I try to write down what I expect from a feed, and let the reader check it every time a record comes in.