MeshWeaver serializes all mesh node content as polymorphic JSON. Rather than using a single global serializer, every MessageHub carries its own JsonSerializerOptions instance backed by a dedicated ITypeRegistry. This design means each hub serializes only the types it knows about and round-trips them correctly — without coupling unrelated hubs to each other's type systems.
Architecture Overview
The hub-per-registry model is the foundation. Two hubs running side-by-side can own completely different type sets, and both write to the persistence layer with their own discriminators.
How Serialization Works
1. Register types with the hub
Types are declared during hub configuration. Every type listed here will be given a $type discriminator on write and resolved back to its CLR type on read.
services.AddMeshWeaver(meshWeaver => meshWeaver
.AddMesh(mesh => mesh
.ConfigureHub(hub => hub
.WithTypes(typeof(Story), typeof(Task), typeof(Person))
)
)
);
2. $type discriminators on the wire
When the serializer writes a registered type, it injects a $type property. Readers use that property to reconstruct the correct CLR type — even when the declared field is typed as object or a base class.
{
"$type": "Story",
"id": "story-123",
"title": "Implement feature",
"status": "InProgress"
}
3. Options flow through every persistence call
The hub's JsonSerializerOptions travels from the hub through the persistence stack all the way to the storage adapter. No intermediate layer substitutes its own options.
Key Interfaces
All persistence contracts accept JsonSerializerOptions explicitly — there is no global fallback.
They are also reactive: every one of these returns IObservable<T>, never Task<T> (see
Asynchronous Calls).
IStorageAdapter
Each backend adapter serializes and deserializes using exactly the options it is given
(src/MeshWeaver.Mesh.Contract/Services/IStorageAdapter.cs):
IObservable<MeshNode?> Read(string path, JsonSerializerOptions options);
IObservable<MeshNode?> Write(MeshNode node, JsonSerializerOptions options);
IObservable<MeshNode> ReadMany(IReadOnlyCollection<string> paths, JsonSerializerOptions options);
IObservable<IReadOnlyList<MeshNode>> WriteMany(/* … */);
IObservable<string> Delete(string path);
IObservable<object> GetPartitionObjects(string nodePath, string? subPath, JsonSerializerOptions options);
There is no
IMeshStorageinterface and noGetNodeAsync/SaveNodeAsync/ReadAsync/WriteAsyncon the adapter contract.GetNodeAsync/SaveNodeAsyncexist only as legacy test-only aliases inMeshWeaver.Fixture/IStorageAdapterTestExtensions.cs, which forward toRead/Write. Application code never touches an adapter directly — see Data Access Patterns.
IMeshService
Query and autocomplete take the caller's options for type resolution during result projection.
Both are reactive; the QueryAsync interface method is gone — IMeshService.Query<T>'s
Initial emission is the old "QueryAsync" snapshot (see
MeshWeaver.Mesh.Contract/Services/MeshQueryExtensions.cs):
IObservable<IReadOnlyCollection<QueryResult>> Autocomplete(/* … */ AutocompleteMode mode = AutocompleteMode.RelevanceFirst /* … */);
Default Configuration
Every hub's options are built by SerializationExtensions.CreateSerializationConfiguration
with these settings:
| Setting | Value | Purpose |
|---|---|---|
PropertyNamingPolicy |
CamelCase |
JavaScript compatibility |
DefaultIgnoreCondition |
WhenWritingDefault |
Compact output |
UnmappedMemberHandling |
Skip |
Tolerate unknown properties on read |
ReferenceHandler |
null |
Reference handling fully disabled |
ReadCommentHandling / AllowTrailingCommas |
Skip / true |
Tolerant parsing |
IncludeFields |
true |
ValueTuple support |
AllowOutOfOrderMetadataProperties |
true |
Accept a $type that isn't the first property (legacy persisted rows) |
🚨
DefaultIgnoreConditionisWhenWritingDefault, notWhenWritingNull— and the difference bites. UnderWhenWritingDefaultaboolproperty whose value isfalseis omitted from the JSON entirely, so a property declaredpublic bool Flag { get; init; } = truesilently fails to round-trip atrue → falsechange: thefalseis not written, and the reader re-applies thetruedefault. When a default-valued member must survive the wire, annotate it[JsonIgnore(Condition = JsonIgnoreCondition.Never)].
WriteIndentedandPropertyNameCaseInsensitiveare not set on the hub's options — they keep theSystem.Text.Jsondefaults (false). Indented output is opt-in per adapter:FileSystemStorageAdapterFactorysupplies awriteOptionsModifierthat copies the options withWriteIndented = truefor on-disk JSON.
Best Practices
Always pass the hub's options
Never create a fresh JsonSerializerOptions for persistence calls — a bare instance has no type registry and silently discards $type discriminators.
// Correct — type registry flows through, and the write is subscribed (cold observable)
adapter.Write(node, hub.JsonSerializerOptions)
.Subscribe(_ => { }, ex => logger.LogWarning(ex, "Write failed for {Path}", node.Path));
// Incorrect — loses type information
adapter.Write(node, new JsonSerializerOptions());
Register every content type
Any type that appears as MeshNode.Content must be registered. Use WithTypes for domain types and WithContentType<T> for well-known content shapes:
hub.WithTypes(typeof(Story), typeof(Task), typeof(Comment))
.WithContentType<AgentConfiguration>()
A missing registration does not throw on write — the discriminator is simply absent, and the object deserializes as JsonElement or object instead of the expected CLR type. Register early; diagnose by inspecting stored JSON for a missing $type.
For a NodeType, WithContentType lives in the type's own hub configuration, and the registration must not depend on an instance of that type existing — see Content-Type Registration for how the platform sweeps definitions at startup, and for the whole commerce surface that went dead when it did not.
The $type discriminator is the SHORT name — register the type on BOTH ends
The $type discriminator defaults to the short type name (StackControl, LayoutStackSkin), not the namespace-qualified full name (MeshWeaver.Layout.StackControl). A short name is only resolvable through the type registry — there is no Type.GetType("StackControl") reflection fallback the way there is for a full name. Two consequences, both non-negotiable:
Every control, skin, and content type must be registered on BOTH the sending hub and the receiving hub. The sender writes
"$type":"StackControl"; the receiver can only turn that back into aStackControlif its own registry mapsStackControl → typeof(StackControl). A type registered on the sender but missing on the receiver comes back as an untypedJsonElement— everyContent is StackControlsoft-cast fails, the value "renders empty", and reactive waits time out (this is the class of bug behind the untyped-JsonElementsync-hub storms). For layout, both ends callAddLayoutTypes(), whose reflection sweep registers everyIUiControl/Skin/StreamMessage— so controls and skins are covered automatically; any non-control record you nest inside control state must be added explicitly (see theWithTypes(...)list ofLayoutAreaDefinition,PivotConfiguration, … inAddLayoutTypes).Never resolve a discriminator with
Type.GetType(reflection) instead of the registry. Reflection only ever worked because the old discriminator was a full name; it silently masked types that were never registered on the receiver. With short names it returnsnulland the value is dropped. The registry is the single source of truth — resolution flows throughITypeRegistry.TryGetType(which also resolves legacy full names via an alias) or, for polymorphic members, through the hub'sPolymorphicTypeInfoResolver(whose derived-type discriminators are the same registry short names). A customJsonConverterthat resolves a$typeby hand (e.g.SkinListConverter) must go through the registry / resolver, never bareType.GetType— otherwise short-named elements are silently skipped.
Backward compatibility: the registry keeps an alias from each type's full name to its definition, so JSON persisted with a legacy
"$type":"MeshWeaver.Layout.StackControl"still deserializes. New writes always emit the short name.
The resolver's diagnostics must never fail the serialization they run in
PolymorphicTypeInfoResolver runs two kinds of work inside GetTypeInfo: the work the serializer
needs (the $type discriminators for a base type's registered subtypes) and diagnostics — the
"unregistered type serialised here" warning, and a scan of the base type's assembly for polymorphic
subtypes nobody registered (which would drop to the nearest ancestor and render empty on the
receiver). Because GetTypeInfo is on the path of every serialization on the hub, a diagnostic
that throws does not merely lose its own warning: it fails whatever message was being written.
That was measured on the one message that must never be lost. MessageService.ReportFailure posts
a DeliveryFailure back to the sender of a request that could not be delivered; serializing that
envelope resolved type info for a base type whose assembly came from a module with an incomplete
dependency closure (a closure without Microsoft.Agents.AI — see
Module Closure Accounting). Assembly.GetTypes() on such an
assembly throws ReflectionTypeLoadException for the one type it cannot load, the exception
escaped the scan, and the hub logged
Failed to post DeliveryFailure message for CreateNodeRequest … - breaking error cascade — the
sender waited for a verdict that never came.
The rule, and how the resolver now honours it:
- Enumerate types loader-safely.
GetTypes()is wrapped in the canonicalcatch (ReflectionTypeLoadException e) → e.Types.Where(t => t is not null): the loadable types are scanned, the rest are skipped. - Report the skipped remainder once per assembly, per hub. The de-duplicated
LoaderExceptionsmessages name the missing dependency, so the warning is the actionable diagnostic for the closure defect — one line, not one per serialized type. - Compute the per-assembly type list once. The loadable types are cached per assembly in an
instance
ConditionalWeakTable<Assembly, Type[]>on the resolver (it dies with the hub's options and, being weak-keyed, does not root a collectible module assembly), so a busy hub does not re-enumerate — and, in the failing case, re-attempt the failed load — for every base type it serializes.
The closure defect itself stays a defect and is fixed where closures are built; the resolver's job is to make sure it can no longer silence the report of some other request's failure.
Use typed query helpers
IMeshService.Query<T> filters by $type and projects directly to T, avoiding manual
casting — reactively, so subscribe rather than await foreach:
// Type-safe query with automatic $type filtering
meshService.Query<Story>(query)
.Subscribe(change => { /* change.Items are already typed as Story */ },
ex => logger.LogWarning(ex, "Query failed"));
Storage Backends
All adapters implement the same interface and accept the same options — swapping backends requires no serialization changes.
| Adapter | Description |
|---|---|
FileSystemStorageAdapter |
Local .json files |
PostgreSqlStorageAdapter |
PostgreSQL (the production backend) |
CosmosStorageAdapter |
Azure Cosmos DB documents |
SqliteStorageAdapter |
SQLite |
InMemoryStorageAdapter |
In-memory store for testing |
Related Topics
- Data Configuration — setting up data sources
- Message-Based Communication — hub architecture