Skip to content

CRUD operations

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

How to create, read, update and delete documents through a repository. Reads that return many documents (LINQ, pagination) are on Querying; the save semantics behind change tracking are on Change tracking and saving.

Create

var cat = new Cat("Milo", new DateTime(2021, 4, 1));
await db.Cats.CreateAsync(cat);              // inserts, assigns the Id

await db.Cats.CreateAsync([cat1, cat2]);     // batch insert

Note. A just-created instance is not a proxy, but it is tracked and it is the instance of its document on the scope: CreateAsync captures its model document, so mutations made after the create persist on the next SaveChangesAsync, and registers it on the identity map, so a same-scope find of it returns the created instance itself — no re-read needed. See Change tracking and saving.

New referred models linked to the creating instance — models whose id is still null — are created too, into their own repositories, before the insert serializes the references.

Inside a transaction that aborts, the create is undone in the scope too: the assigned ids return to null (those of the auto created referred models included) and the instances leave the identity map and the change tracking, so the same instance creates anew — by the automatic retry of a transient failure, or by your own code.

Read one

FindOneAsync returns the entity and throws ScriniumEntityNotFoundException if it's missing; TryFindOneAsync returns null instead. Both accept an id or a predicate:

Cat cat  = await db.Cats.FindOneAsync(id);
Cat? one = await db.Cats.TryFindOneAsync(id);
Cat byName = await db.Cats.FindOneAsync(c => c.Name == "Milo");

Within a scope, a find by id is served from the identity map — no database round trip — when a full instance is already loaded or created. A loaded summary still hits the database, to be upgraded in place to the full document.

Update — mutate and save

Scrinium has no UpdateAsync. You load a tracked entity, mutate it (directly or through its domain methods — see Domain models), and flush the unit of work:

var cat = await db.Cats.FindOneAsync(id);
cat.Rename("Miloh");
await db.SaveChangesAsync();     // persists every changed model of this context

db.SaveChangesAsync() saves all tracked changes; repository.SaveChangesAsync(model) saves one specific model. By default only the changed members are written, atomically. Full semantics — member-level updates, concurrency, schema guarding — are on Change tracking and saving.

Replace

ReplaceAsync writes the whole document, regardless of change tracking. Use it as an explicit escape hatch (e.g. after a heavy rebuild). It refreshes denormalized references by default:

await db.Cats.ReplaceAsync(cat);                              // whole-document replace
await db.Cats.ReplaceAsync(cat, updateDependentDocuments: false);

The instance doesn't need to be the tracked one you loaded: replacing with a brand-new instance carrying the same id works too — including one of a different type of the repository's model hierarchy, which upgrades the stored document to the new type in place.

Delete

await db.Cats.DeleteAsync(id);      // by id
await db.Cats.DeleteAsync(cat);     // by instance (evicts from identity map)

A domain delete also propagates in background to the documents referencing the deleted model, applying the origin delete policy each reference declares — by default the reference is removed.

DeleteManyAsync is a raw bulk delete by filter — fast, but it skips the delete propagation and does not touch any scope's identity map:

long removed = await db.Cats.DeleteManyAsync(c => c.Birthday < cutoff);

Warning. After DeleteManyAsync, instances already loaded in a scope keep being returned by finds on that scope (use UnregisterLoadedModel if that matters). Saving their changes won't recreate the deleted documents. The references pointing at the deleted documents stay dangling too: the missing origin references scan (see Repositories) finds and removes them.

Atomic server-side updates

For concurrency-safe counters, sets and conditional updates, operate on the server atomically instead of load-mutate-save. Reach the driver collection with AccessToCollectionAsync, or use the built-in atomic helpers.

The strongest pattern encodes the invariant in the filter, so the update can't race. A balance debit is the classic example — an overdraw simply matches no document:

// amount < 0 for a debit; the filter guarantees the balance never goes negative.
await db.Wallets.AccessToCollectionAsync(collection =>
    collection.FindOneAndUpdateAsync(
        Builders<Wallet>.Filter.And(
            Builders<Wallet>.Filter.Eq(w => w.Id, walletId),
            Builders<Wallet>.Filter.Gte(w => w.Balance, -amount)),
        Builders<Wallet>.Update.Inc(w => w.Balance, amount)));

Built-in helpers cover the common shapes without dropping to the driver collection:

Method Does
TryFindOneAndUpdateAsync(filter, update, options) Atomic find-and-update, returns the pre/post model.
TryFindOneAndSetFieldAsync / TryFindOneAndAddToSetAsync Set a field / add to a set atomically.
UpdateManyAsync(filter, update) Bulk $set/$inc/… over matching documents.
UpsertAsync / UpsertSetFieldAsync / UpsertIncrementAsync / UpsertAddToSetAsync Find-and-modify with insert of onInsertModel when absent.

Warning. The server-side write bypasses the unit of work: a model already loaded in the same scope won't reflect a change you make this way — read it fresh in a new scope when you need the updated value. A model returned by the TryFindOneAnd* helpers still materializes on the scope: registered and tracked when not loaded yet, or the already-loaded (unrefreshed) instance when one is. The upsert helpers instead detach the pre-image they return, and skip the auto-creation of new referred models: an onInsertModel referencing a model without id throws instead of persisting a broken reference.

The upsert helpers write the onInsertModel through update instructions, one per element of its serialized form (its id excluded). An element the update writes stays out, and an element the update writes inside splits into its sub elements, down to the updated field: a nested update keeps the siblings of the field it touches, so the document created when nothing matches carries the same elements CreateAsync writes for that model, with the update applied.

Update field names are paths, and the element names composing them — the top level ones, and those of a split element — have to work as path segments. When they can't, the upsert refuses the model with a detailed InvalidOperationException instead of writing a document different from the one an insert writes:

The onInsertModel serializes Why the upsert can't write it
an element whose name contains a . — a member mapped onto a field named that way, a dictionary key, a custom serializer naming elements from data the field would land nested instead of literal
an element with an empty name no update path addresses it
an array, a scalar or a null where the update writes inside it can't be set beside the updated field, and the update alone would create a nested document in its place

Keep such a member out of the onInsertModel and write it with CreateAsync, or give it a value the update can complete — an empty dictionary rather than a null, for instance. A name of any shape inside an element the update leaves whole is stored as an insert stores it.


Next: Querying for reads that return many documents, Change tracking and saving for save internals, or Transactions to group writes atomically.

Clone this wiki locally