Skip to content

Domain models

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

Your domain classes are plain C# objects, but to be persisted, lazy-loaded and change-tracked by Scrinium they must follow a few rules. This page is the complete contract for a persisted model.

Two kinds of model

Kind Implements Identity Mutability
Entity IEntityModel<TKey> Yes — an Id of type TKey Mutable through methods
Value object IModel No Immutable after construction; lives only inside an entity or another value object

Only entities are stored as top-level documents and handled by repositories. Value objects are serialized inline within their owner.

The model interfaces

Scrinium defines the interfaces; you implement them — usually via your own base classes, like the ones below. (Etherna.Scrinium.Core.Domain.Models also ships ready-made ModelBase / EntityModelBase<TKey> implementations with these exact names; the bases below show their essential shape — the shipped EntityModelBase<TKey> also implements id-based Equals/GetHashCode.)

public interface IModel
{
    IDictionary<string, object>? ExtraElements { get; }   // MongoDB overflow bag
}

public interface IEntityModel : IModel
{ }

public interface IEntityModel<TKey> : IEntityModel
{
    TKey Id { get; }
}
  • ExtraElements captures document fields not mapped to a property while a document deserializes. It's what lets a schema drop a member without changing its id — deserialization tolerates added and removed fields (see versioned schemas) — and what a schema's fix function reads to migrate old values. The bag lives only inside the load: Scrinium empties it once deserialization (and the fix, when present) has run, and never writes extra data back on save.
  • IEntityModel carries no member of its own: it is the marker distinguishing entity models from plain embedded models. Unlinking related models on delete needs no hook on the model: each reference declares its own origin delete policy, propagated in background when the model is deleted through its repository.

Recommended base classes

Define two abstract bases and derive your domain from them (as in First steps):

public abstract class ModelBase : IModel
{
    public virtual IDictionary<string, object>? ExtraElements { get; protected set; }
}

public abstract class EntityModelBase<TKey> : ModelBase, IEntityModel<TKey>
{
    public virtual TKey Id { get; protected set; } = default!;
}

Model dates are DateTimeOffset UTC instants (UtcNow); how to persist them as plain BSON Dates — and when to derive a creation instant from an ObjectId id instead — is configured per member in Model mapping.

The rules

1. Everything is virtual

Scrinium subclasses your models with a proxy, generated at compile time by the source generator shipped inside the Etherna.Scrinium.Core package, to add lazy loading and change tracking signaling. Only virtual members can be overridden, so all properties and methods must be virtual. A non-virtual member silently loses lazy loading and change tracking.

2. A protected parameterless constructor

The serializer needs to create an empty instance before filling it. Provide a protected (or public) parameterless constructor alongside your real constructors:

public Cat(string name, DateTime birthday) { Name = name; Birthday = birthday; }
protected Cat() { }   // for the serializer

3. Writable members need a setter — non-public is fine

A member is serialized both ways only if it has a setter. Keep setters protected to protect your invariants; the serializer sets them by reflection. A property without a setter (a computed value like Age) is ignored by the serializer by default. A schema fix function writes a member behind a non-public setter with ReflectionHelper.SetValue — see Versioned schemas.

4. Encapsulate collections, expose read-only

Back a collection with a private field and expose it as IEnumerable<T> (or another read-only interface), never null (empty at most). This keeps mutation under your control and avoids null checks on load:

private List<Kitten> _kittens = [];
public virtual IEnumerable<Kitten> Kittens => _kittens;

public virtual void AddKitten(Kitten kitten) => _kittens.Add(kitten);

Exposing a mutable collection type (List<T>, an array, IDictionary<>) is legal, but every read of it flags the model for a diff at save: a change made through the handed-out collection can't be intercepted, so Scrinium assumes one may have happened (Change tracking and saving). With read-only exposure, reads stay free when the element type is itself immutable or an entity (see the value object rules below). Casting a read-only view back to its mutable type to sneak a change past detection is unsupported.

5. Domain methods need no annotations

A summary reference fully loads its document when a missing member is accessed. Property accesses are intercepted directly, and a method that reads or writes a backing field (like AddKitten above) is analyzed at compile time: the source generator computes the properties each method touches — direct backing field accesses included, following the non virtual helpers of the model — so a summary loads the full document before the method runs on partial state. A method whose source the generator can't see (a base class compiled in another assembly) conservatively loads the full document when invoked on a summary.

6. Let MongoDB assign the Id

Leave Id unset on new entities when its type has an id generator; it's assigned on insert. A common setup stores a string id as a MongoDB ObjectId and generates it automatically, configured in the model map:

schema.IdMemberMap.SetSerializer(new StringSerializer(BsonType.ObjectId))
                  .SetIdGenerator(new StringObjectIdGenerator());

ObjectId and Guid ids get their driver generators without configuration (Guid also needs its representation claimed with a custom serializer map — see Custom serializers). An id type without a generator — an int, a custom serialized domain primitive — is assigned by the application before insert.

7. An Id is a value, of a committed type

An entity is addressed by an atomic key: repositories, identity map and references all key on the id value, and a document valued id is the only shape MongoDB reads as an operator expression instead of a value — a caller sending {"$ne": null} as id would otherwise match, delete or overwrite an arbitrary document. So Scrinium refuses an id that doesn't serialize to a value:

  • Composite ids are not supported. A class mapped id, a dictionary, an interface, a BsonDocument or BsonValue member fail fast at engine build with ScriniumInvalidIdMemberException (see Exceptions reference). Serialize a composite key into a value — a string like "tenant:code" — and map its components as ordinary members if you need to query them.
  • object ids are refused too, because they don't commit to a type: the values with no BSON type equivalent serialize as discriminated documents, and the others read back as the type of their BSON type — an enum id writes 1 and reads back an Int32. An application that really keys on heterogeneous values declares it mapping its own serializer for object (Custom serializers).
  • A custom serializer emitting a document is invisible to the engine build, so it's refused where it renders: every operation addressing a document by its key throws a FormatException, and so does the create, since a document written with an unaddressable id couldn't be read, updated or deleted afterwards. TryFindOneAsync reads such an id like any other unparsable key, an id matching nothing.

A value object

A value object implements only IModel and is immutable — no Id, no setters exposed, constructed complete:

public class GeoCoordinate : ModelBase   // ModelBase : IModel
{
    public GeoCoordinate(double lat, double lon) { Latitude = lat; Longitude = lon; }
    protected GeoCoordinate() { }

    public virtual double Latitude { get; protected set; }
    public virtual double Longitude { get; protected set; }
}

Keep value objects genuinely immutable: protected/init-only setters and no public mutating methods. A value object exposing public setters or business methods counts as mutable state, and reading any member that hands it out (directly, or nested inside a collection or another value object) flags the owner model for a diff at save (Change tracking and saving). References to other entities never count: their changes are tracked on their own repository.


Next: Model mapping to declare how these models serialize, Repositories to store and query entities, or References and denormalization to relate them.

Clone this wiki locally