Use with messaging and job libraries
None of these libraries needs a package written for it, but for the schema Confluent's serdes register with the
Schema Registry. One that serializes with System.Text.Json writes a value object
as its underlying value, through the converter the generator puts on it, and reads it back through TryCreate, so a
value the type rejects fails on receipt rather than reaching a handler. One that serializes with Newtonsoft.Json needs
the converter of AdCodicem.ValueObjects.NewtonsoftJson, added by settings.AddValueObjects(), as
JSON explains. Two rules hold for all of them:
- A message contract holding value objects makes its members required, so a producer that drops one fails the message rather than deliver a default instance, as A member missing from the payload shows.
- A key the library reads as a primitive, a saga correlation property or an entity key, takes the underlying value,
.Value.
Each section names the version it was run against. A recipe that was not run says so.
MassTransit
On MassTransit 8.5.11, the default System.Text.Json serializer needs nothing: the values travel bare, and a value its type rejects turns the receive into a fault carrying the rule's message, so the message goes to the error queue.
With Newtonsoft.Json, add the converter on both sides. Without it, an int value object goes out as "7", and a
rejection says only Could not cast or convert. MassTransit.Newtonsoft sets DateParseHandling.None but leaves
FloatParseHandling at Double, which loses the scale and the digits of a decimal
(Numbers); AddValueObjects() sets both:
x.UsingInMemory((context, cfg) =>
{
cfg.UseNewtonsoftJsonSerializer();
cfg.UseNewtonsoftJsonDeserializer();
cfg.ConfigureNewtonsoftJsonSerializer(settings => settings.AddValueObjects());
cfg.ConfigureNewtonsoftJsonDeserializer(settings => settings.AddValueObjects());
cfg.ConfigureEndpoints(context);
});
The probe added the converter and the date handling by hand, without the float handling; AddValueObjects() adds the
three, and was not run through MassTransit itself.
Give a message initializer value objects, not raw values. Fed raw values, it drops what it cannot convert, and the
message holds defaults: a consumer received {"orderId":"00000000-…","customer":"","quantity":0}. The System.Text.Json
converter refuses to write a default its type rejects, so such a message fails to publish with a JsonException, and
only a value object that accepts its zero travels, as zero (inferred from the converter, not run).
A saga correlates on a Guid: CorrelateById(x => x.Message.OrderId.Value) (not run). MassTransit.MessagePack is
not covered, AdCodicem.ValueObjects.MessagePack included: it writes each value object
as a wrapper holding Value and the private _value, and reads it back without validation, through a resolver no
application can reach. MassTransit 9 needs a commercial licence, and was not run.
NServiceBus
On NServiceBus 10, SystemJsonSerializer needs nothing, and a value its type rejects sends the message to the error
queue without retries, as a MessageDeserializationException wrapping the JsonException.
A value object cannot be a saga correlation property:
OrderId is not supported for correlated properties ... Guid,String,Int64,UInt64,Int32,UInt32,Int16,UInt16, or use a custom saga finder.
Correlate on a primitive property holding .Value, or write a saga finder. NServiceBus.Persistence.Sql 9.0.4 stores
saga data with Newtonsoft.Json, so its JSON settings need the converter; that part of its API was not run.
Rebus
Rebus 8 serializes with System.Text.Json by default: nothing to do.
Kafka with Confluent's Schema Registry
Confluent's JSON serdes, JsonSerializer<T> and JsonDeserializer<T> of Confluent.SchemaRegistry.Serdes.Json,
serialize with Newtonsoft.Json, register the JSON Schema NJsonSchema generates for the message with the Schema Registry,
and validate every message against it before producing it. NJsonSchema describes a value object as the bare string
of its type converter, whatever its rules, so that with ValueObjectConverter in the settings the producer refuses its
own message, Schema validation failed for properties: [#/Quantity, #/Consent, ...], one path per number and boolean
the converter writes as such. AdCodicem.ValueObjects.NJsonSchema describes each one as its underlying type instead,
with its rules: the converter first, then the package, and the same settings to both serdes.
dotnet add package AdCodicem.ValueObjects.NJsonSchema
var schemaSettings = new NewtonsoftJsonSchemaGeneratorSettings();
schemaSettings.SerializerSettings.AddValueObjects(); // AdCodicem.ValueObjects.NewtonsoftJson
schemaSettings.AddValueObjects(); // AdCodicem.ValueObjects.NJsonSchema
var serializer = new JsonSerializer<Order>(registry, new JsonSerializerConfig(), schemaSettings);
var deserializer = new JsonDeserializer<Order>(registry, new JsonDeserializerConfig(), schemaSettings);
Run on Confluent.SchemaRegistry.Serdes.Json 2.16.0 over a substitute registry client, no registry server:
- The schema registered holds one definition per value object, referred to wherever it is used, its underlying
type with the pattern, lengths, bounds,
enum, example (x-examplein draft-04, which has noexamples) and names of known values declared on it. A nullable member is the reference besidenull,oneOf. - The message carries numbers and booleans as such,
"Quantity":7,"Consent":true, and passes the producer's validation. One holding a value its type refuses, a defaultCustomerId, is never produced: the converter refuses to write it,JsonSerializationExceptioncarryingvalue_object.required. A message whose value its type refuses, tampered with or written by another program, is refused by the consumer's converter with the code of the rule,value_object.too_shortfor an IBAN of two characters, whichValueObjectErrors.TryGetCodereads off theJsonSerializationException. Confluent's ownInvalidDataException, from its validation, carries no code. - Without the converter, the schema describes what the settings write, the type converter's text: numbers and
booleans travel as strings,
"7"and"True", a refusal carries no code, and the default consumer cannot read aDateTimeOffsetvalue object back, as Dates explains. Add the converter. - A value object is a member.
JsonSerializer<T>andJsonDeserializer<T>take a class, so a value object cannot be the key or the value type of a topic itself: it travels inside a message. - What Confluent validates. It runs NJsonSchema's validator with no format validator, so
format: emailchecks nothing. That validator throwsInvalidCastExceptionon a JSON integer beyond the range of along, whatever the schema says, aulongvalue object abovelong.MaxValueamong them, with or without this package. It compares a closed set's values by their text, a real read as adouble, so theenumof a closed set of decimals lists each as a double,20.0for20.00, and one no double holds rounded,0.3333333333333333for a third written to 28 places, which the type refuses: give such a set values a double holds. An OpenAPI document, which no such validator reads, lists each as written.
Moving a subject to the new schema. A topic whose subject already holds the schema NJsonSchema wrote without the
package holds a bare string for each value object. Under the registry's BACKWARD compatibility, read from the
source of Confluent's JSON Schema checker and not run against a registry, every rule the new schema adds is a change it
refuses, PATTERN_ADDED, MIN_LENGTH_ADDED, MAX_LENGTH_ADDED, MINIMUM_ADDED, MAXIMUM_ADDED, and TYPE_CHANGED
for a number or a boolean, a string until then. Register it under a new subject, or change the subject's compatibility level for the
switch alone. A consumer with the converter reads the old numbers, "7" as 7, but not the old booleans, "True".
Azure Service Bus, Event Hubs, Event Grid and Storage Queues
A body built with BinaryData.FromObjectAsJson goes through System.Text.Json: it carries bare values, and is
validated when read. Received by an Azure Functions trigger, a message's binding data, its identifier or its insertion
time, binds to a value object through the Functions converter. A value
object put in ApplicationProperties or EventData.Properties throws at send,
SerializationException: Serialization failed due to an unsupported type, Domain.OrderId.. Put the underlying value
there, which AMQP carries natively, and read it back with TryCreate. Not ToString(): it writes what a formatting
hook writes, which does not always read back.
message.ApplicationProperties["orderId"] = orderId.Value;
if (received.ApplicationProperties.TryGetValue("orderId", out var raw) && raw is Guid value
&& OrderId.TryCreate(value, out var id, out var validation))
{
// ...
}
Durable Functions, Durable Task and Dapr Workflow
JsonDataConverter.Default is System.Text.Json: inputs and outputs carry bare values, and are validated when read.
Only the converter was run, not an orchestration. An entity key is a string: pass .Value.
Dapr
DaprClient, for service invocation, publish and subscribe, and state, serializes with System.Text.Json: nothing to
do. Strongly typed actor remoting goes through DataContractSerializer, which writes <Customer/><OrderId/><Quantity/>
and reads back defaults, unless the value objects' assembly opts into XML serialization, which makes it write
and validate them as it does any member (expected, not run). Or turn on JSON serialization in the actor proxy options,
as Dapr documents (not run).
Hangfire
Hangfire 1.8.25 serializes job arguments with Newtonsoft.Json, without the converter by default: numbers go out as strings, and a rejection loses its rule. Add the converter:
GlobalConfiguration.Configuration.UseRecommendedSerializerSettings(settings => settings.AddValueObjects());
AddValueObjects() also sets FloatParseHandling.Decimal and DateParseHandling.None, which the probe set by hand.
Without the first, a decimal argument came back with its scale and digits lost: 12.50 as 12.5. A job stored
before the converter was added still loads, since the converter reads a number written as a string, as the type
converter wrote it; a boolean value object is the exception, written as "True", so drain the jobs carrying one
first. A tampered argument fails the job with the rule's message.
Quartz
Quartz 4.3.0 binds job data through the generated TypeConverter, but under its default
PropertyMismatchBehavior.Ignore, a value the type rejects leaves the property default, and the job runs. Set
PropertyMismatchBehavior.Throw, or use IJob<TInput>, which reads its input through System.Text.Json and refuses an
invalid payload with the value object's message.