Skip to content

DbContext and engine

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

A database context is Scrinium's unit of work: it groups the repositories of one domain boundary, tracks what you change, and flushes it on SaveChangesAsync. Every context is backed by a process-wide engine. Understanding this split — and the per-scope identity map — is key to using Scrinium correctly.

Defining a context

Derive from DbContext, declare your repositories, and list the model-map collectors. Optionally override SeedAsync for seeding:

public interface ISampleDbContext : IDbContext
{
    IRepository<Cat, string> Cats { get; }
}

public class SampleDbContext : DbContext, ISampleDbContext
{
    public IRepository<Cat, string> Cats { get; } = new Repository<Cat, string>("cats");

    protected override IEnumerable<IModelMapsCollector> ModelMapsCollectors =>
        [new ModelBaseMap(), new CatMap()];

    protected override Task SeedAsync() => base.SeedAsync();
}

Register it with AddDbContext. You never write a constructor that takes dependencies — Scrinium builds and wires the context.

Engine vs context

  • DbContextEngine is a keyed singleton, one per context type. Reach it via context.Engine.
  • DbContext is scoped — one instance per request or job — and holds the unit-of-work state.

IDbContext does not extend IDbContextEngine: engine members live under .Engine.

On the engine (IDbContext.Engine)

Member Use
Client, Database The MongoDB client and database.
GetMongoCollection<T>(name, settings?, isReadOnly?) A driver collection guarded by the engine's access limitations (escape hatch).
MapRegistry Registered model maps and schemas.
Options This context's IDbContextOptions.
SupportsTransactions True when the deployment allows transactions (replica set, sharded or load-balanced), detected from the cluster topology; false while the topology is still undiscovered.
StartSessionAsync(ct) Start a driver session.
RunWithExclusiveAccessAsync(action, lockOnRead) Run under exclusive access (used by seeding/migrations).
IsExclusiveReadEnabled / IsExclusiveWriteEnabled Whether an exclusive operation is locking access in this process.
DbContextLock The db context lock, serializing seeding and migrations across application instances. Throws InvalidOperationException on a read-only context.
GetResourceLock(resourceNamespace, resourceId) The resource lock of an application resource, as an IResourceLock object. Same read-only denial as DbContextLock.
Identifier Names the context in its operation log and keys the db context lock; defaults to the context's type name.
ProxyGenerator, SerializerRegistry, DiscriminatorRegistry, SerializerModifierAccessor, ExecutionContext, DbMaintainer, DbMigrationManager, DbContextType, IsSeededCache, Logger Infrastructure components and engine metadata.

On the context (IDbContext)

Member Use
repositories (your properties) + RepositoryRegistry Read and write documents.
ChildDbContexts The child context instances attached from this context's scope (see Child contexts below).
ChangedModelsList Proxy models flagged as change candidates; the save diff decides what actually writes (see Change tracking and saving).
SaveChangesAsync(ct) Persist pending changes (see Change tracking and saving).
ExecuteInTransactionAsync(...) Run a block in a transaction.
DbOperations Log of Scrinium's own operations (_db_ops).
DocumentMigrationList Registered migrations.
IsSeeded / SeedIfNeededAsync(lockWaitTimeout?, lockLeaseDuration?) Seeding state and trigger.
ExecuteMigrationAsync, TryStartMigrationAsync, GetLastMigrationsAsync, GetMigrationAsync, IsMigrationRunningAsync Migration control.
TryAcquireResourceLockAsync(resourceNamespace, resourceId, mode?, leaseDuration?) / IsResourceLockedAsync(resourceNamespace, resourceId) Acquire and inspect the application's resource locks.
IsMemberLoaded, LoadValuesAsync (two overloads), IsOutdatedModel Summary inspection, explicit preloads and outdated-instance detection — see References and denormalization.
StartTransientModelsScope() A scope evicting at dispose the models materialized inside it, bounding the memory of massive scans — see Migrations.
TryGetLoadedModel(repository, modelId) / UnregisterLoadedModel(modelId, model) Escape hatches over the identity map (below): the instance already loaded for a document, and the eviction of one instance from the next loads deduplication.

The plumbing the library itself invokes on the context — change candidate marking, loaded model registration, model document tracking, lazy load reaction hooks — is not part of IDbContext: DbContext implements it explicitly on infrastructure interfaces (IProxyModelsDbContext, invoked by the generated proxy models and public only because the proxies compile into your assembly, and IInternalDbContext, internal to the library), so the surface you code against is application API only.

The identity map

Within one context instance (one scope), Scrinium keeps an identity map: each document materializes one model instance, EF-style.

  • Loading the same document twice returns the same instance; a reference to an already-loaded document resolves to it instead of a new object.
  • A created model is the instance of its document too: CreateAsync registers it (the auto-created referred models included), so a load of that document in the creating scope returns the plain instance you built, and a save referencing it keeps it as the member value.
  • A full load upgrades a summary in place: if you first saw an entity as a denormalized summary and later load it fully, the same instance gains the extra data.
  • FindOneAsync by id returns an already-loaded full instance, or a created one, without a database round trip; a loaded summary still queries, to be upgraded in place.
  • Deleting a document evicts it from the map; so does a reload that finds the document has changed type (the outdated instance is replaced).

Warning — fresh data means a new scope. Because reads are deduplicated, a second read in the same scope won't reflect concurrent changes made by other scopes until the sync point (SaveChangesAsync) or a new scope. If you need the latest state — in a long-lived background loop, for instance — open a new DI scope. UnregisterLoadedModel is the escape hatch for a single instance.

For large read-only scans, disable the identity map (and change tracking) with the no-cache serializer modifier so it doesn't grow one entry per document — see Querying and Best practices and pitfalls.

DI lifetimes and captive dependencies

Warning. Never inject a DbContext into a singleton. A singleton lives for the whole process, so it would pin one scoped context — and its identity map — forever (a captive dependency). Symptoms: ever-growing memory, stale reads, and changes saved through the wrong context. Keep every consumer scoped or transient.

For a background service (a hosted singleton), inject IServiceScopeFactory and open a fresh scope per work cycle:

public sealed class Worker(IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stop)
    {
        while (!stop.IsCancellationRequested)
        {
            using var scope = scopeFactory.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<ISampleDbContext>();
            // ... use db for this cycle, then dispose the scope ...
        }
    }
}

Child contexts

A context can declare child contexts with ParentFor<TChild>() in its options. Parent and children are resolved from the same DI scope, and each context keeps its own identity map, keyed by repository — so a model always materializes once in the context that owns its repository, whether you load it from that context directly or reach it through a cross-context reference from the parent. Saving the parent cascades to persist pending changes in its children (each on its own connection — child saves don't enlist in the parent's transaction). Children can even live in a different physical database:

services.AddScriniumWithHangfire()
    .AddDbContext<IMainDbContext, MainDbContext>(options =>
    {
        options.ConnectionString = "mongodb://localhost/main";
        options.ParentFor<ISharedDbContext>();      // declare the child
    })
    .AddDbContext<ISharedDbContext, SharedDbContext>(options =>
    {
        options.ConnectionString = "mongodb://localhost/shared";
    });

For example, a service can keep some user state in a shared context and still persist both the user and its shared info in a single SaveChangesAsync() on the parent.

Building a context outside DI

For tests or non-ASP.NET hosts you can build an engine and attach a context yourself (BuildEngine + AttachToEngine) instead of using AddDbContext. See the core unit tests (DbContextTest) for the pattern.


Next: Repositories to read and write, Change tracking and saving for save semantics, or References and denormalization to relate documents.

Clone this wiki locally