Skip to content

Architecture overview

Mirko Da Corte edited this page Aug 25, 2026 · 7 revisions

A tour of how Scrinium is put together — the packages, the engine/context split, and how a request flows through it.

Packages

Scrinium is a stack of layered libraries. Etherna.Scrinium.Core holds the ODM logic and performs all data access through the MongoDB driver; the others adapt it to a host and a task runner — Etherna.Scrinium.AspNetCore also constructs the MongoClient it hands to the engine, and the Hangfire package delegates its job storage to the external Hangfire.Mongo. The core package also ships a Roslyn source generator (Etherna.Scrinium.Core.Generators, under analyzers/) that emits the model proxies at compile time in every consuming project — no setup needed.

graph TD
  UI["Etherna.Scrinium.AspNetCore.UI<br/>(admin dashboard)"] -. optional .-> AN
  Meta["Etherna.Scrinium<br/>(meta package)"] --> AN["Etherna.Scrinium.AspNetCore<br/>(DI integration)"]
  Meta --> HF["Etherna.Scrinium.Hangfire<br/>(task runner)"]
  AN --> Core["Etherna.Scrinium.Core<br/>(the framework)"]
  HF --> Core
  Core --> Driver[("Etherna MongoDB<br/>driver fork")]
Loading

See Packages and feeds for which one to install. The driver is the Etherna fork (Etherna.MongoDB.*), not the official one.

The engine and the context

This is the single most important idea in Scrinium. There are two related objects per database:

  • DbContextEngine — one per process (singleton). It owns everything built once and shared: the MongoDB client and database, the MapRegistry and versioned schemas, the discriminator registry, the serializer modifiers, the proxy generator, seeding state, exclusive access in this process, and the db context lock across instances. The serialization pipeline is wired to the engine.
  • DbContext — one per scope (request or background job). It is the unit of work: it exposes the repositories, tracks changed models (ChangedModelsList), holds the per-scope identity map, and provides SaveChangesAsync and ExecuteInTransactionAsync. Each context attaches to the singleton engine of its type, reachable as IDbContext.Engine.
graph TD
  Engine["DbContextEngine — singleton<br/>client · MapRegistry · schemas<br/>seeding · exclusive access · db context lock"]
  A["DbContext — scope A (request)<br/>repositories · identity map<br/>changed models · SaveChanges"]
  B["DbContext — scope B (Hangfire job)<br/>repositories · identity map<br/>changed models · SaveChanges"]
  A -->|.Engine| Engine
  B -->|.Engine| Engine
Loading

Two consequences you will feel constantly:

  • IDbContext does not extend IDbContextEngine. Engine-level members (Client, Database, MapRegistry, ProxyGenerator, Options, exclusive access, sessions…) live under context.Engine; scope-level members (repositories, SaveChangesAsync, ChangedModelsList, seed/migration facades) live on the context.
  • Fresh data = a new scope. Within one scope, the identity map returns the same instance for the same document, so a second read won't see other scopes' concurrent changes until you SaveChangesAsync (the sync point) or open a new scope. This makes injecting a context into a singleton a bug (a captive dependency) — see Best practices and pitfalls.

Details: DbContext and engine.

Execution context

Scrinium needs to know "which flow am I in?" during serialization and lazy loading — for example to find the current context when a summary reference lazy-loads its full document. That ambient state is the execution context: an HTTP-request context for web requests, an async-local context for background work. It's wired automatically by AddScrinium; you only touch it on threads that have no ambient context (some background services), where you open one explicitly. See Execution contexts.

Serialization and mapping

How models become documents:

  • The MapRegistry holds a model map per type. A map can carry several schemas, each stamped with an immutable schema id written into the document, so multiple schema versions live in one collection and old documents deserialize without a mass migration (Versioned schemas).
  • Reference serializers implement denormalization: a referenced entity is stored as a compact summary (at minimum its id), and unloaded members lazy-load on access (References and denormalization).
  • Serializer modifiers tweak behavior for a block of operations — most importantly the no-cache modifier, which skips change tracking and the identity map for large read-only scans.

Details: Model mapping.

Repositories and the unit of work

You read and write through repositories (IRepository<TModel, TKey>). All collection access funnels through AccessToCollectionAsync, which is also your hook for driver-level atomic operations (e.g. FindOneAndUpdateAsync). As you mutate tracked models, the generated proxy flags them as change candidates; SaveChangesAsync diffs each against its captured document and persists only the changed members in one atomic statement per model (Change tracking and saving). When the deployment supports it, that flush runs inside an implicit transaction.

Background maintenance

Some work must happen off the request path and is handed to a task runner (ITaskRunner, Hangfire by default — Background tasks):

  • UpdateDocDependenciesTask propagates a changed summary to every document of the application that denormalized it, keeping denormalized copies in sync.
  • DeleteDocDependenciesTask applies the origin delete policies of the references to a deleted document — removing the references, or cascading the delete.
  • MigrateDbContextTask runs a context migration under exclusive access (a dry run skips it), holding the db context lock claimed by its start so no other instance migrates or seeds the same context meanwhile.

Putting it together — a request

sequenceDiagram
  participant R as Request
  participant S as DI scope
  participant C as DbContext
  participant E as DbContextEngine
  participant Q as Task runner
  R->>S: begin scope
  S->>C: resolve DbContext (attach to Engine)
  C->>E: read via repository (schemas, driver)
  E-->>C: model (registered in identity map)
  C->>C: mutate model (change tracking)
  C->>E: SaveChangesAsync (member-level, maybe in a transaction)
  C->>Q: enqueue dependency-update task
  R->>S: end scope (context disposed)
  Q->>E: refresh denormalized references (own scope)
Loading

Where to go next

Clone this wiki locally