Skip to content

Repositories

Mirko Da Corte edited this page Sep 15, 2026 · 14 revisions

A repository gives typed access to one collection of entities. You declare repositories on your DbContext; every read and write goes through them.

Declaring a repository

Construct a Repository<TModel, TKey> with the collection name and expose it (behind an interface):

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");
    // ...
}

TModel must be an entity (IEntityModel<TKey>); TKey is its id type. To configure indexes, opt into whole-document saves, or deny writes on the collection, build it from a RepositoryOptions<TModel> instead of a bare name:

public IRepository<Cat, string> Cats { get; } = new Repository<Cat, string>(
    new RepositoryOptions<Cat>("cats")
    {
        IndexBuilders =
        [
            (Builders<Cat>.IndexKeys.Ascending(c => c.Name), new CreateIndexOptions<Cat> { Unique = true })
        ],
        SaveWithDocumentReplace = false   // default: save only changed members
    });

See Indexes for index configuration, Change tracking and saving for SaveWithDocumentReplace, and Read-only access for the IsReadOnly option.

You can declare more than one repository over the same model type (or over a type hierarchy); when you do, references to that type need to know which repository is their origin — see References and denormalization.

What a repository exposes

Category Members Page
Create CreateAsync(model), CreateAsync(models) CRUD operations
Read one FindOneAsync(id|predicate), TryFindOneAsync(id|predicate) CRUD operations
Query QueryElementsAsync(...), QueryPaginatedElementsAsync(...), FindAsync(...) Querying
Update (tracked) mutate a tracked model, then SaveChangesAsync Change tracking and saving
Update (atomic) AccessToCollectionAsync, TryFindOneAnd*, UpdateManyAsync, Upsert* CRUD operations
Replace ReplaceAsync(model) CRUD operations
Delete DeleteAsync(id|model), DeleteManyAsync(filter) CRUD operations
Indexes RepositoryOptions.IndexBuilders, GetDefinedIndexModelsAsync, BuildNewIndexesAsync, DeleteOldIndexesAsync Indexes
Counts EstimatedDocumentCountAsync(), CountDocumentsBySchemaIdAsync() below
Deprecated schemas BuildDeprecatedSchemaDocumentsMigration() Migrations
Deprecated schema id elements CountDeprecatedSchemaIdDocumentsAsync(), MigrateDeprecatedSchemaIdDocumentsAsync() below
Missing origin references FindMissingOriginReferencesAsync(), RepairMissingOriginReferencesAsync() below

Every repository also exposes ModelType, KeyType, Name, IsReadOnly, ModelIdToString, and its DbContext.

Counting documents

EstimatedDocumentCountAsync reads the collection size from its metadata, at a constant cost whatever the collection holds. The number is an estimate: it can drift after an unclean shutdown, and on a sharded cluster it includes orphaned documents.

var estimatedCount = await Cats.EstimatedDocumentCountAsync();

CountDocumentsBySchemaIdAsync groups the documents by the model map schema id they carry, resolved from the _s element or from the deprecated _m one. Documents carrying no schema id element are counted aside, and schema ids that no map declares are reported too — together, they are the documents no registered schema accounts for:

var (documentsBySchemaId, documentsWithoutSchemaId) = await Cats.CountDocumentsBySchemaIdAsync();
foreach (var (schemaId, documentsCount) in documentsBySchemaId)
    Console.WriteLine($"{schemaId}: {documentsCount}");

Warning. MongoDB serves this grouping with a full collection scan — indexing the schema id element does not change that — so the cost grows with the collection. Size the collection with EstimatedDocumentCountAsync before counting a large one. The Admin dashboard exposes both, counting one collection per request.

Both work on read-only repositories and db contexts: counting is a read.

Documents carrying a deprecated schema id element

A document written before the schema id element took its _s name carries it in the deprecated _m element (ModelMapSchema.DeprecatedIdElementName), still recognized while reading it — see Versioned schemas. CountDeprecatedSchemaIdDocumentsAsync counts those documents, matching the element at their root: a document whose root carries _s was written whole by a version writing it, its sub-documents included, so the root tells the whole document.

var documentsToMigrate = await Cats.CountDeprecatedSchemaIdDocumentsAsync();

MigrateDeprecatedSchemaIdDocumentsAsync rewrites them. Renaming the root element wouldn't be enough — a document nests a schema id as deep as its sub-documents and its summaries go — so each counted document is deserialized and written back whole with its current active schema, which stamps _s at every level. It is a migration restricted to those documents, and behaves like one: a document failing deserialization or write is skipped and reported in the returned MigrationResult, keeping the content it has, and the documents referencing the migrated ones are not updated.

var result = await Cats.MigrateDeprecatedSchemaIdDocumentsAsync();
Console.WriteLine($"{result.MigratedDocuments} migrated, {result.TotDocumentErrors} failing");

The count works on read-only repositories and db contexts — counting is a read — while the migration throws UnauthorizedAccessException there, like every write.

Warning. The count scans the collection, and the migration reads and rewrites every document it selects: both belong to on-demand maintenance. The Admin dashboard counts them with the model schemas of a collection, on request, and rewrites them through the Rewrite deprecated schemas option of a migration start, which covers every document left on a deprecated schema.

Missing origin references

A reference whose origin document doesn't exist anymore has nothing to load (see the missing origin documents section of that page). What dangles comes from the deletes the origin delete propagation doesn't cover; this scan finds it. FindMissingOriginReferencesAsync scans the collection: for every reference element path of the registered schemas — secondary ones included, since a reference written by a deprecated schema still points to its origin document — it reads the distinct referenced ids server side and verifies them, as stored, against the origin repository of the reference:

var report = await Cats.FindMissingOriginReferencesAsync();
foreach (var pathReport in report.PathReports)
    Console.WriteLine(
        $"{pathReport.ElementPath} -> {string.Join(", ", pathReport.OriginRepositoryNames)}: " +
        $"{pathReport.MissingOriginIdsCount} missing origins, " +
        $"{pathReport.ReferencingDocumentsCount} documents affected");

Each MissingOriginReferencesPathReport carries the reference element path, the origin repositories the ids were verified against — a source declared on a child db context resolves like a lazy load would — the full count of the missing origin ids with a listing capped at MissingOriginReferencesPathReport.MaxTrackedMissingOriginIds entries (100), the count of the documents referencing the listed ids — a lower bound when the cap drops some — with their ids listed under their own cap (MaxTrackedReferencingDocumentIds, 100), and the OriginDelete its mapping declares. Null references are not reported: they address no origin document. UnverifiableElementPaths lists apart the reference paths the scan can't verify — a dictionary written with its keys as element names, a fixed array position in the path (an ArrayOfArrays dictionary value), or an origin repository that doesn't resolve on the current scope.

RepairMissingOriginReferencesAsync runs the same scan and repairs each path the way its mapping declares a deleted origin is propagated — the dangling references it finds are exactly the ones that propagation never reached:

OriginDelete What the repair does
KeepReference Leaves the path alone, and doesn't even scan it: those references dangle by design.
RemoveReference Pulls a reference hosted as an array item out of its array, sets any other one to null — reading as a null reference from then on. A raw bulk repair: it writes server side without loading models.
DeleteReferencingDocument Deletes the referencing documents through the repository domain delete, batch by batch inside a transient models scope, so they propagate their own reference policies in turn.

Every update matches the missing origin id itself, so a reference concurrently rewritten to another document is left alone, and unverifiable paths stay untouched.

var repair = await Cats.RepairMissingOriginReferencesAsync();
foreach (var pathRepair in repair.PathRepairs)
    Console.WriteLine(
        $"{pathRepair.ElementPath} ({pathRepair.RepairMode}): " +
        $"{pathRepair.MissingOriginIdsCount} missing origins, " +
        $"{pathRepair.UpdatedDocumentsCount} documents updated, " +
        $"{pathRepair.DeletedDocumentsCount} deleted");

repairModesByElementPath overrides the declared policy of the paths it names, and a name that isn't a verifiable reference of the collection fails with a detailed ArgumentException instead of applying to nothing. dryRun executes the whole repair with its collection writes simulated, and progressAsync reports what a path brought so far while it runs.

The find works on read-only repositories and db contexts — scanning is a read — while the repair throws UnauthorizedAccessException there, like every write.

Warning. The scan reads every referenced id of the collection — one aggregation per reference path, plus chunked existence reads on the origin collections — so its cost grows with the collection and its references. Size the collection with EstimatedDocumentCountAsync before scanning a large one. The Admin dashboard exposes the find on request, and runs the repair as an operation, one collection at a time.

The collection escape hatch

AccessToCollectionAsync hands you the driver IMongoCollection<TModel> surface — guarded by the engine's access limitations — for operations Scrinium doesn't wrap; most usefully atomic server-side updates (see the balance example in CRUD operations):

var count = await Cats.AccessToCollectionAsync(collection =>
    collection.CountDocumentsAsync(Builders<Cat>.Filter.Empty));

By default (handleImplicitDbExecutionContext: true) it opens a db execution context around the operation, so documents materialized inside it register on the current scope (identity map and change tracking). Enlistment in an ambient transaction is automatic, with or without the flag, for every operation on the context's collections that can run in a transaction (change stream watches and estimated document counts stay session-less).


Next: CRUD operations to write data, Querying to read it.

Clone this wiki locally