Serialize to JSON
A value object travels as its underlying value: an Iban is a JSON string, a Quantity a JSON number. It is
never wrapped in an object, so a client sees exactly what it would see if the property were a primitive.
System.Text.Json, reflection-based
Nothing to do. Every generated value object carries its own [JsonConverter], so JsonSerializer, ASP.NET
Core's default options and HttpClient's JSON extensions all serialize it as the bare value:
JsonSerializer.Serialize(new { iban = Iban.Create("FR7630006000011234567890189") });
// {"iban":"FR7630006000011234567890189"}
Reading goes through TryCreate, so an incoming value is normalized and validated like any other. A rejected
value, or a token of the wrong kind, throws a JsonException naming the type and the reason. A value object
also works as a dictionary key. The key carries the underlying value too, in the form the value travels in, whatever a
formatting hook writes, and it is read back the same way and validated like a value: a key the value object rejects
throws a JsonException as well.
System.Text.Json, source-generated
A source-generated JsonSerializerContext needs one line more. One source generator never sees another's
output, so the System.Text.Json generator cannot see the [JsonConverter] this library emits. Name the
hand-written factory from AdCodicem.ValueObjects.Json, which it can see:
dotnet add package AdCodicem.ValueObjects.Json
[JsonSourceGenerationOptions(Converters = [typeof(ValueObjectJsonConverterFactory)])]
[JsonSerializable(typeof(AccountResponse))]
public partial class ApiJsonContext : JsonSerializerContext;
It is declared at compile time, on the context, so there is nothing to remember when the options are built.
The factory hands each value object the converter generated for it, which the generator registers with the value
object's descriptor. The package belongs in the assembly that declares the context: a domain project declaring the
value objects needs only AdCodicem.ValueObjects, the contracts and the generator, and its converters reach the
context all the same, with nothing built by reflection, which trimming and native AOT would not support. Only a value object written by hand has no generated
converter, and gets a general-purpose one from the factory instead.
A construction of a generic value object, Reference<PurchaseOrder>, is serialized as any other value object, by
either serializer. Its registration registers the generic definition alone, though, so the factory finds the generated
converter of the construction by reflection, the first time it meets it. Under native AOT, register the constructions
the context serializes, as Where a value object can be declared
shows.
Explicit options
The same package adds AddValueObjects() to JsonSerializerOptions, for a composition root that prefers to say
so, or for options that must cover value objects written by hand against the contracts:
var options = new JsonSerializerOptions().AddValueObjects();
In ASP.NET Core MVC, AddControllers().AddValueObjects() already does this for you.
Large integers
Int128 and UInt128 value objects are written as JSON strings: a JSON number cannot carry them without
losing precision in most clients.
Number handling
A value object over a number follows JsonSerializerOptions.NumberHandling as its underlying type does, since
System.Text.Json leaves the number handling of a custom converter to the converter:
AllowReadingFromStringreads"1250.00"as well as1250.00, and reads the text as System.Text.Json reads a number from text: whole, with no white space, no group separator and no culture, so"1,000"and" 5"are refused, as they are for the bare value;WriteAsStringwrites every number as a string,"1250.00", and an indented writer lays it out as any other;- over a
doubleor afloat,AllowNamedFloatingPointLiteralswritesNaNand the infinities as"NaN","Infinity"and"-Infinity", and either reading option reads them back.WriteAsStringwrites them as text too; with neither, writing one throws, as it does for the bare value.
The OpenAPI document describes what these options put on the wire, as it describes the bare value.
What is read still goes through the value object's rules: a Latitude bounded to ±90 refuses "NaN" whatever the
options let the reader read.
Newtonsoft.Json
For code, SDKs and message contracts that have not moved to System.Text.Json:
dotnet add package AdCodicem.ValueObjects.NewtonsoftJson
var settings = new JsonSerializerSettings
{
DateParseHandling = DateParseHandling.None, // hand the converter the text of every string
};
settings.Converters.Add(new ValueObjectConverter());
One converter covers every value object, with the same rules as System.Text.Json and the same values on the wire:
a number or a boolean as such, anything else as a string in the same form, Int128 and UInt128 included. The
text is the same too, but for a whole decimal, double or float: Newtonsoft.Json always writes a real with a
fraction, 1250.0 where System.Text.Json writes 1250, and each serializer reads the other's text as the same
value. The converter reads only the kind of token it writes, so a number in a string, or a string where a number
belongs, is refused as JSON rather than converted. A rejected value throws a JsonSerializationException naming
the type and the rule.
Newtonsoft.Json reads each token before a converter sees it, under the serializer's settings, and its defaults change what the converter is handed.
Dates
Under the default DateParseHandling, Newtonsoft.Json turns a string that looks like a date into a DateTime, and
converts one written with an offset to local time. What a value object makes of that date:
- A
DateTimevalue object reads it as System.Text.Json reads the text. - A
DateTimeOffsetvalue object reads it as System.Text.Json reads the text when the text ends inZor names no zone, and refuses any text with an explicit offset, which the date has lost. That includes its own output: aDateTimeOffsetis always written with its offset,+00:00for UTC, so under the default settings it cannot be read back. - A string value object has lost its text, and refuses it.
A date that no longer says what the text said is refused rather than read as another value. Set
DateParseHandling to None, as above, and every string reaches the converter as text.
Numbers
Under the default FloatParseHandling, Newtonsoft.Json reads a number with a fraction or an exponent as a
double, and a decimal value object keeps the fifteen to seventeen significant digits a double carries. When
decimal value objects carry more, set FloatParseHandling.Decimal, which keeps all of them, as System.Text.Json
does:
settings.FloatParseHandling = FloatParseHandling.Decimal; // for decimal value objects only
It applies to every number of the payload, and a decimal holds a narrower range than a double. Under it, a
double or float value object beyond about ±7.9 × 10²⁸ makes the reader throw a JsonReaderException, and one
smaller than a decimal's 28 decimal places keeps only the digits that fit in them — none below about 10⁻²⁸, so it
reads as zero. Leave the default when a payload carries such values. FloatParseHandling.Decimal also gives an
object or JToken member a decimal rather than a double for such a number.