Skip to content

Versioned schemas

Mirko Da Corte edited this page Sep 11, 2026 · 16 revisions

Scrinium stores a schema id in every document, so several versions of a type can live in one collection at once. The id lives in the _s element of the document (ModelMapSchema.IdElementName); documents carrying it in the deprecated _m element (ModelMapSchema.DeprecatedIdElementName) deserialize the same way, and the Admin dashboard brings them onto the current element. When you change a model, you don't migrate all its documents — you register the old shape as a secondary schema, and old documents keep deserializing correctly.

Active and secondary schemas

  • The active schema (the one you pass to AddModelMap) serializes new writes and is stamped into the documents it produces.
  • Secondary schemas are read-only: they teach Scrinium how to deserialize documents written by earlier versions. Add them on the builder returned by AddModelMap.

A document read through a secondary schema is upgraded to the active schema the next time it's saved (the member-level save falls back to a migrating whole-document replace — see Change tracking and saving). So documents drift forward lazily, as they're touched.

When a change needs a new schema id

Not every model change is a new version — the document model is flexible:

  • Adding or removing members keeps the schema id. A document missing a new member deserializes it to its default; elements of a removed member land in the ExtraElements bag, emptied after the load.
  • Renaming a member or changing its type needs a new active schema id, keeping the old shape as a secondary schema: the same element can't deserialize two ways under one id.

A member added without a new schema id relies on the model contract: Scrinium builds a model through its parameterless constructor, so a member the document doesn't carry keeps its default. A model that isn't an entity model and declares no parameterless constructor, or whose own constructor feeds a member with no setter, is built by that constructor instead: it takes every argument from the document, so a member added to it as a constructor argument needs a new schema id, like a rename.

Registering an old version

IModelMapBuilder<TModel> AddSecondarySchema(
    string id,
    Action<BsonClassMap<TModel>>? modelMapSchemaInitializer = null,
    string? baseSchemaId = null,
    Func<IDbContext, TModel, Task<TModel>>? fixDeserializedModelFunc = null);

A renamed field

If a property was renamed in code, map it in the secondary schema to its old element name so old documents still bind:

dbContextEngine.MapRegistry.AddModelMap<Cat>("d2f…v2", map => map.AutoMap())
    .AddSecondarySchema("a1b…v1", map =>
    {
        map.AutoMap();
        map.GetMemberMap(c => c.Birthday).SetElementName("BirthDate");   // the pre-rename name
    });

Repairing the model on read — fixDeserializedModelFunc

Some evolutions need logic, not just remapping: hoist a value out of ExtraElements, split a field, or recompute a denormalized value. fixDeserializedModelFunc runs after deserialization, receives the current db context scope and the loaded model, and returns the repaired model:

.AddSecondarySchema(
    "c3d…v1",
    fixDeserializedModelFunc: (dbContext, order) =>
    {
        // e.g. rebuild StatusHistory from a legacy single value kept in ExtraElements
        order.RebuildLegacyState();
        return Task.FromResult(order);
    });

Note — The fix function is the last point where ExtraElements is readable: right after it runs, Scrinium empties the bag, so a loaded model doesn't carry stale extra data around and never persists it back.

A single map can carry one active schema plus several secondary ones, each with its own fix function migrating legacy ExtraElements and renamed elements into the current shape.

The fix function belongs to root model maps: the schemas of a reference configuration build through IReferenceModelMapBuilder<TModel>, which doesn't accept one. A reference document is a denormalized summary — repair logic lives on the schemas of the origin document, and every member a summary doesn't carry lazy-loads from the repaired origin.

Writing non-public members — ReflectionHelper

A fix function repairs a model from outside it, and a persisted model keeps its setters non-public. Etherna.Scrinium.Core.ReflectionHelper, the static helper the serializers themselves read and write members with, writes a member whatever the accessibility of its setter:

.AddSecondarySchema(
    "c3d…v1",
    fixDeserializedModelFunc: (dbContext, order) =>
    {
        // the v1 document carried the status in a "State" element: write it through the protected setter
        ReflectionHelper.SetValue(order, o => o.Status, (string)order.ExtraElements!["State"]);
        return Task.FromResult(order);
    });

The lambda selects a property or a field of the model with a direct member access (o => o.Status); a lambda selecting no member throws InvalidOperationException. The writes of a fix function precede the capture of the model document that change tracking diffs against: they are loaded state, never changes to save.

Member Behavior
SetValue(destination, memberLambda, value) Writes the member the lambda selects, whatever the accessibility of its setter. The member resolves on the runtime type of destination: a lambda typed on an interface writes the property implementing it.
SetValue(destination, memberInfo, value) Writes a field or a property through its MemberInfo, whatever its accessibility. A readonly field and a property without a setter are left untouched, without error.
GetValue(source, memberInfo) Reads a field or a readable property; null for any other member.
GetMemberInfoFromLambda(memberLambda, actualType = null) The MemberInfo of the property or field a member access lambda selects. With actualType a class, a property declared by an interface resolves to its implementation on that type.
FindPropertyImplementation(interfacePropertyInfo, actualType) The property of actualType implementing an interface property, explicit implementations included.
GetWritableInstanceProperties(objectType) The instance properties with a setter, public and non-public, declared on the type and on its bases; computed once per type.

Warning — Outside a fix function, a loaded entity is a proxy flagging itself as changed through the setter overrides it emits for public, protected and protected internal setters (Change tracking and saving): a reflection write through a private setter flags nothing, and unless another member set or method call flags the model, the save skips it.

Inheritance chaining and subtype changes

  • baseSchemaId names which schema of the base model type's map this schema chains to for its inherited members (by default, the base's active schema) — set it when a secondary schema must bind to the base type's matching secondary schema.
  • AddSecondarySchema<TOverrideNominal>(...) (where TOverrideNominal : class, TModel) deserializes that schema's documents as TOverrideNominal instead of TModel — for documents whose stored concrete type changed across versions.

Fallback for unknown ids

Documents with an unrecognized or missing schema id (legacy data written before schemas existed) are handled by a fallback, if you register one:

.AddFallbackSchema(map => map.AutoMap())                 // a model map for unknown-id documents
// or
.AddFallbackCustomSerializer(new MyLegacySerializer());  // a custom serializer instead

Without a fallback, a referenced document with an unknown id deserializes by reading only its reference id (other members lazy-load from the origin), and a root document is read by the active schema. The id fallback is reserved for these schemas and can't be used as a normal schema id.

Both those defaults are reported: Scrinium logs a warning naming the model type and the unrecognized id, once per pair (up to a hundred distinct pairs per serializer). The warning is the only signal of an otherwise invisible degradation. A configured fallback logs nothing: there the handling of unknown documents is your decision, not a surprise.

Lazy schemas vs eager migrations

Two ways to evolve stored data — use both as needed:

Secondary schema DocumentMigration
When it runs On read, per document, in memory Eagerly, rewriting all documents
Cost None up front; documents upgrade as touched A full pass over the collection
Use it when You just need old documents to keep working You need every document on the new schema now (e.g. for an index or query)

You can pair them: a type with secondary schemas for element renames and a DocumentMigration to actively rewrite documents when required.

To decide, count how many documents still sit on each schema id: CountDocumentsBySchemaIdAsync on the repository (see Repositories), or the Model schemas section of the Admin dashboard, which also reports schema ids no map declares and documents carrying none.


Next: Migrations for eager rewrites, Model mapping for the mapping basics, or References and denormalization for relating documents.

Clone this wiki locally