-
Notifications
You must be signed in to change notification settings - Fork 4
References and denormalization
This is Scrinium's flagship feature. You store a related entity as a compact summary embedded in the referencing document (so you read the aggregate in one query, no joins), lazy-load any member you didn't denormalize, and Scrinium keeps every denormalized copy in sync automatically when the origin changes.
An entity is stored once, in its own collection. Everywhere else you reference it: the reference stores the entity's id plus whichever members you chose to denormalize (the summary), inline in the owner document. On read you get a summary instance; touching a member you didn't denormalize lazy-loads the full document from the entity's origin repository.
Entity models must be referenced, not embedded: the maps freeze detects every member serializing an entity model through its class map (the full-document serialization — collections and reference configurations included) and fails the engine build with a detailed
ScriniumEmbeddedEntityModelExceptionnaming each violating member. A custom serializer — set on the member or mapped for the type — opts out, for value-object-like models. (Value objects, by contrast, are always embedded — see Domain models.)
A reference is a member serialized by a ReferenceSerializer<TModelBase, TKey>. Its configuration is a
model map in its own schema-id space that maps only the summary members — and,
because that space doesn't infer base types, one map for every type in the referenced hierarchy, up to
the last base before object. The common case — one repository per referenced type — needs no source
declaration:
using Etherna.MongoDB.Bson;
using Etherna.MongoDB.Bson.Serialization.Serializers;
using Etherna.Scrinium.Core;
using Etherna.Scrinium.Core.Serialization.Serializers;
// Summary of a User: its id plus one denormalized field.
// One AddModelMap per level of User's hierarchy — ModelBase, EntityModelBase<string>, User.
public static ReferenceSerializer<User, string> UserReference(IDbContextEngine engine) =>
new(engine, config =>
{
config.AddModelMap<ModelBase>("1a2b…modelbase", map => { }); // no summary members at this level
config.AddModelMap<EntityModelBase<string>>("2b3c…entitybase", map =>
{
map.MapIdMember(m => m.Id); // required — every summary must carry the id
map.IdMemberMap.SetSerializer(new StringSerializer(BsonType.ObjectId)); // the same id serializer the origin uses
});
config.AddModelMap<User>("3f2c…user-summary", map =>
{
map.MapMember(u => u.DisplayName); // denormalized into every reference
});
});Every summary must carry the id. The id is the reference's reason to exist — the identity-map key, and the handle each non-denormalized member lazy-loads from; a summary that deserializes without one is dropped, and the reference resolves to
null. Map it explicitly, on your entity base: a level you leave out gets an empty default map that drops whatever it declares — the id included. The reference config is a separate schema space, so re-declare the id's serializer here to match the origin; it isn't inherited from the model map. For a polymorphic reference, add a map for each concrete subtype too.
Apply it to a referencing member in the owner's map with SetSerializer:
dbContextEngine.MapRegistry.AddModelMap<Order>("…order", map =>
{
map.AutoMap();
map.GetMemberMap(o => o.Owner).SetSerializer(UserReference(dbContextEngine));
});For a collection of references, wrap the reference serializer in an
EnumerableSerializer — for example a User summary applied both to an
Order.Owner and, as a list, to a Team.Members.
The source repository is the origin a reference lazy-loads from (and the key for the identity map). Scrinium resolves it for you when it's unambiguous, and lets you declare it when it isn't:
-
Implicit (default). With no declaration, it resolves at engine build by walking from the referenced type up its base chain to the nearest level declaring repository properties for the type. Several repositories over the same model type at that level — or only key-incompatible ones — fail fast at startup with
ScriniumAmbiguousRepositoryException; repositories at different hierarchy levels don't conflict, the nearest wins. -
Declared. When there are multiple candidates, name the source with the typed factory; the typed selector parameter fixes the generic arguments — no explicit type arguments needed — and compatibility is checked at compile time:
ReferenceSerializer.Create( dbContextEngine, // config also declares User's base hierarchy — elided here (see "Defining a reference serializer") config => config.AddModelMap<User>("…user-summary", map => { map.MapIdMember(u => u.Id); }), sourceRepository: (IMyDbContext dbContext) => dbContext.Users);
The untyped constructor parameter (
sourceRepository: dbContext => ((IMyDbContext)dbContext).Repo) stays as an escape hatch for the one case invariance can't express — a base-typed repository sourcing derived-typed references. -
Cross-context. To reference an entity owned by another db context, name that context's type in the same typed factory, and declare it a child context on the owner's options with
ParentFor<TChild>():// in the referencing context's map ReferenceSerializer.Create( dbContextEngine, // config also declares User's base hierarchy — elided here (see "Defining a reference serializer") config => config.AddModelMap<User>("…user-summary", map => { map.MapIdMember(u => u.Id); }), sourceRepository: (IDirectoryDbContext dbContext) => dbContext.Users); // in startup, on the referencing context's options options.ParentFor<IDirectoryDbContext>();
The reference then behaves like any other: it binds the child's repository, lazy-loads through it, and auto-creates a
null-id model into it. The referenced model belongs to the child context — its identity map, change tracking and saves live there — so one document materializes one instance across parent and child, and its changes persist with the child's save, which the parent'sSaveChangesAsync()cascades.Note. Dependency updates cross the parent–child boundary within one application: a child model changed through any context of the application refreshes the summaries denormalized into the parent's documents. They never cross applications — a process that doesn't host the parent context can't reach its documents — so denormalize members from a child context only when every application writing the child's collections also hosts the referencing context; keep the summary id-only otherwise, unless a stale copy is acceptable.
You can link an entity that was never persisted — its id is still null — and just save the
owner: every repository write that serializes references (CreateAsync, SaveChangesAsync,
ReplaceAsync) first creates the new referred models into their source repositories, then
persists the owner with complete references.
var owner = new User("Ada"); // never persisted: Id is null
var order = new Order(owner);
await db.Orders.CreateAsync(order); // creates the User first, then the Order
// owner.Id is now assigned, and the User is tracked like an explicitly created model-
Ids are assigned upfront, through the id generator of the mapped id member, before any insert:
references between new models serialize complete in any creation order — mutual references
included (a new
Orderholding a newUserthat references theOrderback works). - Discovery is transitive: a new model referred by another new model is created too.
- Each auto-created model joins the unit of work like an explicit
CreateAsync: later mutations are diffed and saved, and the created instance is the instance of its document on the identity map, kept as the reference value by the save refresh. InsideSaveChangesAsyncthe creations into this context's repositories enlist in the implicit save transaction; a creation into a child context's source repository runs on the child's connection, outside it. - A model that already has an id is never re-created: auto-creation triggers on the
nullid only.
Warning. A reference to a model without id fails the write with a detailed
InvalidOperationExceptionwhen it can't auto-create — when its id member configures no id generator. The same guard covers every write that serializes anull-id reference without auto-creation — the upsert helpers and raw collection writes — where the driver surfaces it wrapped in aBsonSerializationExceptionnaming the member: a persisted id-less reference would deserialize tonull, silently losing the link. Id-less references already persisted keep reading asnull.
Denormalize the members you read together with the owner; leave the rest to lazy-load. On the identity map:
- a full load upgrades an already-loaded summary in place;
- a freshly deserialized summary merges its denormalized members into an instance already loaded as a summary — so you accumulate denormalized data rather than lose it.
Which members count as loaded is decided by the reference document itself: an element carried by the document marks its member loaded, mapped through the reference schema that deserialized it — regardless of the member's setter accessibility (private setters included). A member the document doesn't carry — even one assigned by a serializer default value during deserialization — is not loaded: its first read lazy-loads the whole document from the origin repository. (A reference read through a custom fallback serializer has no schema mapping elements to members: there the observed sets decide.)
When you only need reference ids, read under the reference serializer modifier
(EnableReferenceSerializerModifier(readOnlyId: true)) — see Querying.
An implicit lazy load is synchronous over the database call. When you know which members you are
about to read, preload them asynchronously with IDbContext.LoadValuesAsync — per instance, or
batched over a collection, grouped per source repository:
// one summary
await dbContext.LoadValuesAsync(team.Owner, u => u.Email);
// a whole collection of summaries: the missing documents load with $in queries
await dbContext.LoadValuesAsync(team.Members, u => u.Email, u => u.CreationDateTime);
var email = team.Members.First().Email; // no lazy load: already fullThe members are a precondition, not a projection: a summary already carrying all of them is
untouched, any other loads its whole document, merged in place through the identity map. A summary
the identity map doesn't hold — read under the no-cache modifier, or evicted by a
transient models scope — upgrades from the loaded instance of its
document, and invalidates like a held one when the document carries
another type. Each repository loads its missing documents with one $in query per chunk of a thousand
ids, so a batch never grows a single command nor materializes its whole result at once — a preload of
ten thousand summaries still costs ten round trips and ten thousand materialized documents, so
page what you preload, and keep a reference array from growing unbounded in a document.
IsMemberLoaded(model, m => m.Member) inspects the current state; the id member is definitionally
always loaded. Reading the ExtraElements bag of a summary never lazy loads
either: the bag lives only inside a load (see Domain models), so a summary has
nothing to load into it. How the context reacts to the
implicit loads that still happen is configured by the ImplicitLazyLoad
option: warn once per member per scope (the default), stay silent, or
deny them throwing ScriniumLazyLoadingException. The modes govern the loads a read triggers.
Writing a reference serializes every member its schema declares, so a summary missing one of them
loads its origin document to complete the document being written: that load always runs, whatever the
mode, and reports with a warning of its own.
A load has nothing to read when the referred document doesn't exist on the origin collection anymore —
deleted, or never created by a flow that persisted the referencing document alone. That is an
inconsistency of the database, and each reference declares how its summaries react to it on a read,
with its MissingOriginDocument mode (a ReactionMode, from Etherna.Scrinium.Core.Options):
| Mode | Reaction |
|---|---|
Warn |
Log a warning once per model type and source repository, per scope, and give up the summary state. The default. |
Silent |
Give up the summary state, reporting nothing. |
Throw |
Deny the load, throwing ScriniumMissingOriginDocumentException with the model type, its id and its source repository. |
The default warns instead of throwing because the state is not always an inconsistency: a model deleted through its repository legitimately leaves its references dangling until the background propagation removes them — a read inside that window must not fail.
Tolerating it (Warn and Silent), the model stops being a summary and keeps the members it never
loaded at their default values — reading them returns null or 0, indistinguishable from
persisted data, and different from the null reference the delete propagation leaves once it
completes. Denying it keeps the model a summary, so it still requires its origin document and a later
read attempts the load again.
Declare it on the reference configuration, beside its model maps:
config.MissingOriginDocument = ReactionMode.Throw;Declare Throw on the references your code takes decisions on: a business flow reading default
values in place of real data is worse than an exception. The trade is availability inside the
deletion window — a Throw reference can fail a read that a Warn one would have degraded.
Both read paths react the same way: the implicit lazy load of a member, and LoadValuesAsync, which
reports every summary whose document the preload didn't find — where the inconsistency is, instead of at
the next member read. A denormalized member is unaffected: it reads from the summary, and no load
runs.
Warning — a save of the referencing model hits this too, and it never degrades. Writing it serializes its reference members, and a reference schema denormalizing a member the summary doesn't carry reads that member, which loads. A dangling reference therefore fails that save, whatever mode the reference declares: the write can't complete the summary it has to persist, and writing the not loaded members at their default values would store them as real data. Repair what dangles before rewriting the documents carrying it — every repository finds and removes such references (Repositories).
Two references to the same model type can declare different modes — one critical, one pointing at documents allowed to disappear. Within a scope one document materializes one instance, whatever the references reaching it, so that instance keeps the strictest mode of the ones that reached it: an opt-out on one reference can't silence another that expects the document to be there.
Finding the dangling references of a whole collection — and removing them — doesn't load anything: every repository scans its reference paths server side and verifies the referenced ids against their origin collections, and the admin dashboard exposes the same scan and removal per collection. See Repositories.
When you change a member that other documents denormalized, SaveChangesAsync enqueues a background
UpdateDocDependenciesTask that propagates the new summary to every document of the application
referencing it. This runs off the request path via the task runner and converges
even after a transaction abort (it's not part of the save transaction); see
Change tracking and saving for what the save enqueues.
The summaries refresh server side, in bulk: one UpdateMany per hosting repository and reference
member path rewrites the summary sub-document of every matching document with the active reference
schema — so summaries persisted with older reference schemas migrate at their first refresh — and no
document content is read back. Edge behaviors to know:
- a referenced model deleted while its update was pending skips the task with a warning; the references then follow the origin delete policy of their reference — removed by default, by the delete propagation the domain delete enqueued;
- a task executed while a flow holds exclusive access (e.g. a running migration) fails and is retried by the task runner, converging on the post-migration state — background propagation never interleaves with a migration;
- summaries hosted behind an unknown document key — a dictionary serialized in document
representation writes its keys as element names — sit on a path the update filter can't address
(querying unknown document keys is unsupported server side), so the task skips the path and those
summaries go stale when their origin changes. The engine build reports every such path, with
the reaction declared by the
NotPropagatedReferencesoption: a warning per element path (the default), silent tolerance, or aScriniumNotPropagatedReferenceExceptiondenying the build. Dictionaries in the array of documents or array of arrays representation keep addressable paths, and their summaries refresh like any other collection member, only on the entries of the changed model; - read-only repositories are skipped: their documents belong to another application, and stay untouched.
A model deleted through its repository (DeleteAsync) propagates in background to the documents
referencing it, applying the origin delete policy each reference declares — the counterpart, at
the source, of the missing origin document reaction above. Declare it on the reference configuration
(OriginDeleteMode lives in Etherna.Scrinium.Core.Options):
config.OriginDelete = OriginDeleteMode.DeleteReferencingDocument;| Mode | Reaction to the origin delete |
|---|---|
RemoveReference |
Remove the reference from the referencing documents: a reference hosted as an array item is pulled out of its array, any other one is set to null. The default: a domain delete never leaves its own references dangling. |
DeleteReferencingDocument |
Delete the referencing documents, with a domain delete that propagates their own reference policies in turn: cascades chain across models, and stop on the documents already deleted — mutual reference cycles included. |
KeepReference |
Keep the reference dangling: its summary reads react per the MissingOriginDocument mode. The explicit opt-out, for summaries that must survive their origin. |
The propagation runs off the request path via the task runner
(DeleteDocDependenciesTask), like the dependency updates: removals write server side, in bulk —
one update per hosting repository and reference member path, matching the deleted id, so a reference
concurrently rewritten to another document stays untouched — while cascades load the referencing
models batch by batch and delete them through their repository. Until the task runs, the references
dangle: reads inside that window react per the MissingOriginDocument mode, whose Warn default
exists exactly for it, while a write that has to complete one of those summaries fails.
What the propagation doesn't cover:
-
raw bulk deletes (
DeleteManyAsync) and deletes performed by other applications: no domain delete, no propagation — the missing origin references scan (see Repositories and the admin dashboard) finds and removes what they leave behind; -
referencing documents of other applications: the propagation reaches the contexts of the
application performing the delete — the deleting context and the
parent contexts declaring it a child, like the dependency updates — never
the documents of an application that doesn't host them: those references behave as
KeepReferencewhatever they declare; - reference paths the propagation can't address with a filter — an unknown document key (a dictionary in document representation), or a fixed array position in the path — reported by the same scan as unverifiable;
- read-only repositories: their documents belong to another application, and stay untouched.
A document can change its concrete type over time, keeping its id — say a Web2Account evolving
into a Web3Account of the same hierarchy, replaced through its repository:
var account = await dbContext.Accounts.FindOneAsync(accountId); // Web2Account
await dbContext.Accounts.ReplaceAsync(new Web3Account(account, etherAddress));Two mechanisms keep references coherent:
-
Persisted summaries follow the change. The replace enqueues the dependency update, which
rewrites every summary with the reference schema of the new type, discriminator included, so
following reads deserialize the reference directly as
Web3Account. The reference serializer must map every concrete type of the hierarchy it can host. -
Loaded instances can't upgrade, so they invalidate. The runtime type of an instance can't
change: when a full load (implicit lazy load, explicit preload, or a repository read) finds the
document with another type of its hierarchy, the fresh instance becomes the loaded one for the
scope — returned by that and every next load — and the outdated instance is invalidated: any
interaction with it throws
ScriniumOutdatedModelTypeException, naming both types. Its id stays readable, andIDbContext.IsOutdatedModel(model)inspects the state:
var author = message.Author; // summary, loaded as Web2Account
try
{
var username = author.Username; // full load finds a Web3Account document
}
catch (ScriniumOutdatedModelTypeException)
{
var current = await dbContext.Accounts.FindOneAsync(author.Id); // fresh Web3Account
}Saving unrelated changes on a document that still references an outdated instance keeps working: the invalidation guards application interactions, not the library's own serialization.
A reference document whose schema id isn't recognized (or is missing) deserializes by reading only
its id; every other member lazy-loads from the origin. You can override this per reference with a
fallback schema or serializer on the reference's own model map. Reference maps
version like root ones — secondary and fallback schemas included — but accept no
fixDeserializedModelFunc: repair logic belongs to the origin document's root
schemas, which every lazy load runs through.
The admin dashboard's document structures render each collection with the summary elements marked and expanded: the map of what each reference costs in stored data, and of what an update of a referenced document rewrites.
- Multiple summary tiers — define more than one reference serializer for the same type (a light preview vs a richer summary) and pick per member.
-
Id-only summary — an empty summary body persists just
_idplus the_sschema id element: a pure reference with everything lazy-loaded. - Denormalize a hot field — copy a frequently-read value (say a role's normalized name) into the summary so a common lookup never touches the origin collection.
Next: Custom serializers for EnumerableSerializer and friends, Background tasks for the
dependency-update task, or Migrations to rewrite documents.
Scrinium — source · issues (SCR) · GNU LGPL-3.0 · info@etherna.io
Getting started
Core concepts
Working with data
Serialization & mapping
Operations & maintenance
Advanced & reference