Bodu.Financial.Serialization.Json - Core concepts
This page is the vocabulary the rest of the documentation assumes. Read it once before the getting-started samples or the financial JSON guide, and refer back whenever a term feels imprecise.
Part of the Numerics & Financial topic. For the high-level shape of the package, start with the introduction; the money vocabulary - minor units, Money<TCurrency> vs Money, CalculatedMoney, MoneyBag - lives in the Bodu.Financial concepts page.
Serialization-agnostic core
Bodu.Financial carries no [JsonConverter] attribute and no reference to System.Text.Json; this package supplies both. The consequence worth remembering: registration is required. Serializing a Money<USD> with a JsonSerializerOptions that has not had AddFinancialJsonConverters called on it does not throw - JsonSerializer falls back to its reflection-based object shape, which does not round-trip. Configure the options before first use; once an options instance has been used, its Converters collection is read-only and AddFinancialJsonConverters throws InvalidOperationException.
Policy
A FinancialJsonPolicy is passed once - to AddFinancialJsonConverters, to AddFinancialJson, or to a converter's constructor - and fixes the wire shape and parsing strictness for every financial type serialized with that options instance:
| Policy | Writes | Reads |
|---|---|---|
Strict (0, default) |
The canonical object shapes. | The canonical shapes only. Property names compare case-insensitively; duplicate properties are rejected; unknown properties are ignored; Money<TCurrency> requires currency == TCurrency.IsoCode. |
Lenient (1) |
Exactly what Strict writes. |
Everything Strict reads, after normalizing lowercase ISO codes to uppercase and trimming surrounding whitespace. |
Compact (2) |
The single-string and flat forms. | The compact forms; money strings in either "19.99 USD" or "USD 19.99" arrangement; an ExchangeRate in either the compact or the canonical object shape. |
A converter constructed without a policy defaults to Strict.
Canonical object shape
Under Strict and Lenient, every money value is an object with an amount (a JSON number) and a currency (a three-letter ISO 4217 string):
{ "amount": 19.99, "currency": "USD" }
Money<TCurrency> reads it by verifying the currency matches the type parameter; Money reads it by resolving the code against the shipped catalogue and rounding to that currency's minor units. On read, amount may also be a numeric string, so a payload produced by a system that cannot carry arbitrary-precision JSON numbers still round-trips.
Compact form
Under Compact, a money value is a single string - the amount rendered in the invariant culture, padded to the value's minor units, a space, and the ISO code:
"19.99 USD"
Reads accept either arrangement ("19.99 USD" or "USD 19.99") and reuse the type's TryParse path for the numeric component. Strict and Lenient never differ under Compact - there is no casing or whitespace ambiguity for the lenient rules to relax.
Scale
A Money can carry an explicit minor-unit scale that differs from its currency's registered minor units - a six-decimal unit price in a two-decimal currency, created with Money.FromExplicitScale(amount, code, minorUnits). The converters preserve it:
- The object shapes add a
scaleproperty -{ "amount": 145.678912, "currency": "USD", "scale": 6 }- and the reader reconstructs the value at that scale, including trailing zeros. A payload withoutscaledeserializes at the registry precision, so documents written before the property existed remain valid. - The compact form encodes the scale in the printed digits -
"145.678912 USD"reads back withMinorUnits == 6. Inference applies only to scales finer than the registry; a coarser scale is preserved only by the object shapes.
Money<TCurrency> has no scale property: its precision is fixed by the currency tag.
Verbatim CalculatedMoney
CalculatedMoney is the unrounded, deferred-arithmetic tier, so its converter writes the full decimal amount exactly as stored - every significant digit and any trailing zeros - with no scale metadata, because the decimal itself carries the precision: { "amount": 0.0325125, "currency": "USD" } or "0.0325125 USD". Settle after transport with RoundToMoney().
Bag shapes
A MoneyBag is a wrapped ISO-keyed map under Strict / Lenient and a flat map under Compact:
{ "balances": { "EUR": 50.00, "USD": 100.00 } }
{ "EUR": 50.00, "USD": 100.00 }
Balances are written in lexicographic ISO order and may be read as numbers or numeric strings. Because the bag prunes zero balances on every operation, a bag deserializes to its canonical form - a "JPY": 0 entry on the wire does not survive the round trip.
Rate and pair shapes
An ExchangeRate observation carries its provenance on the wire. Strict / Lenient write the full canonical object in declaration order - from, to, date (ISO yyyy-MM-dd), rate, provider, isInverted - adding observedRate when the rate was derived from the reverse pair and fetchedAtUtc (round-trip O format) when the observation records a fetch instant. Compact combines the currencies into one "pair": "EUR/USD" property, drops isInverted unless it is true, and keeps the same optional members. The reader accepts both shapes regardless of policy - the presence of pair versus from / to selects the parse.
A CurrencyPair is { "from": "USD", "to": "JPY" } in the object shapes and "USD/JPY" under Compact.
Factory vs closed converters
Money<TCurrency> is an open generic, so AddFinancialJsonConverters registers a MoneyOfTCurrencyJsonConverterFactory that binds the concrete TCurrency per request and produces the matching MoneyOfTCurrencyJsonConverter<TCurrency>. The other five types are non-generic and register as single converters. Any of them can be added to JsonSerializerOptions.Converters by hand - new MoneyOfTCurrencyJsonConverter<USD>(FinancialJsonPolicy.Compact) covers Money<USD> only - when a narrower registration is wanted.
Keyed options in the container
services.AddFinancialJson(policy) registers one configured JsonSerializerOptions as a keyed singleton under FinancialJsonServiceCollectionExtensions.JsonOptionsKey ("Financial"). Keying keeps the financial converters from leaking into the application's default JSON options - an ASP.NET Core response pipeline, for example - and lets a consumer resolve them explicitly with GetRequiredKeyedService<JsonSerializerOptions>(JsonOptionsKey) or a [FromKeyedServices] parameter.
Failure modes
Every malformed payload surfaces as JsonException; the converters never coerce silently:
| Input | Result |
|---|---|
A token that is not an object under Strict / Lenient, or not a string under Compact |
JsonException naming the expected form. |
Missing amount or currency; a missing required ExchangeRate property |
JsonException naming the missing property. |
Duplicate amount, currency, scale, or any ExchangeRate property |
JsonException - duplicates are rejected. |
amount that is neither a number nor a numeric string; currency that is not a string |
JsonException reporting the type mismatch. |
currency that does not match TCurrency.IsoCode on Money<TCurrency> |
JsonException - currency mismatch. |
A code that is not three uppercase letters, or is not in the shipped ISO 4217 catalogue (Money, CalculatedMoney, MoneyBag) |
JsonException - unknown currency rejected. |
scale that is not an integer, or outside 0-28 |
JsonException. |
A compact string TryParse rejects ("19.99", "USD/") |
JsonException carrying the offending text. |
balances that is not an object; a balance that is neither a number nor a numeric string |
JsonException. |
| A truncated document | JsonException - unexpected end. |
Where to go next
- Getting started - install + runnable minimal samples for every concept above.
- Introduction - the converter table and scenario index.
- Financial JSON serialization guide - every wire shape under every policy, with migration notes.
- Monetary precision & unit pricing - where explicit scale and
CalculatedMoneycome from. - Bodu.Financial concepts - the money vocabulary.
- Bodu.Financial.Serialization.Json API reference - full type-by-type docs.