Write Interceptors
Interceptors let you observe and mutate writes as they happen. The after-hook runs inside the same transaction as the write — after it succeeds and before commit — so it sees the generated id/version and can perform transactional side effects (e.g. a transactional outbox).
There are two granularities:
- Per-document (
IDocumentInterceptor) — fires forInsert,BatchInsert(per item),Update,Upsert, andRemove. - Bulk / set-based (
IDocumentBulkInterceptor) — fires once forExecuteUpdate,ExecuteDelete, andClear, which never materialize the affected documents.
Per-document interceptors
Section titled “Per-document interceptors”public sealed class AuditInterceptor : IDocumentInterceptor{ public Task BeforeWrite(DocumentWriteContext ctx, CancellationToken ct) { // ctx.Document is mutable here; ctx.Id may be unassigned for auto-generated ids. // Throw to abort the write (and roll back the surrounding unit). return Task.CompletedTask; }
public Task AfterWrite(DocumentWriteContext ctx, CancellationToken ct) { // Runs inside the transaction with ctx.Id / ctx.Version populated. Console.WriteLine($"{ctx.Operation} {ctx.TypeName} #{ctx.Id} (source: {ctx.Source})"); return Task.CompletedTask; }}
opts.AddInterceptor(new AuditInterceptor());Or register type-scoped lambdas:
opts.OnBeforeWrite<Order>((ctx, ct) =>{ if (ctx.Document is Order o && o.Total < 0) throw new InvalidOperationException("Total cannot be negative"); return Task.CompletedTask;});
opts.OnAfterWrite<Order>((ctx, ct) => outbox.Enqueue(ctx.Id, ctx.Operation, ct));DocumentWriteContext carries Operation (Insert/Update/Upsert/Delete), Source
(Direct/Temporal), DocumentType, TypeName, Id, mutable Document (null for delete-by-id),
Version, Services (the DI scope for this write), and
Store (a transaction-visible store handle).
Semantics
Section titled “Semantics”BeforeWriteruns before serialization. Mutations toctx.Documentare persisted. Throwing aborts the write and propagates;ctx.Cancel()instead replaces the write with your own.AfterWriteruns after the core write succeeds, inside the transaction, before commit. If the write throws,AfterWritedoes not fire.- Multiple interceptors run in registration order.
- A
Restore(temporal) write fires interceptors withSource == Temporalso you can distinguish it from a direct write — the internal history-table writes are not surfaced.
Bulk interceptors
Section titled “Bulk interceptors”Set-based operations never load the affected documents, so per-document interceptors don’t fire for them — they get their own interceptor with the translated predicate and affected count.
public sealed class BulkAudit : IDocumentBulkInterceptor{ public Task BeforeBulkWrite(DocumentBulkContext ctx, CancellationToken ct) => Task.CompletedTask;
public Task AfterBulkWrite(DocumentBulkContext ctx, CancellationToken ct) { Console.WriteLine($"{ctx.Operation} {ctx.TypeName}: {ctx.AffectedCount} rows"); return Task.CompletedTask; }}
opts.AddBulkInterceptor(new BulkAudit());DocumentBulkContext carries Operation (Update/Delete/Clear), WhereClause (the translated
predicate, null for Clear-all), Assignment (for ExecuteUpdate), AffectedCount (after only),
QueryAs<T>() (the originating query, null for Clear), and Store.
Replacing a write (ctx.Cancel())
Section titled “Replacing a write (ctx.Cancel())”BeforeWrite can do more than observe and mutate: call ctx.Cancel() and the store performs no write
at all for that operation — you have done it yourself. That’s what makes an interceptor able to change
the shape of an operation, e.g. turn a delete into an update (see Soft Delete,
which is built entirely this way and ships in the box).
public sealed class TombstoneInterceptor : IDocumentInterceptor{ public async Task BeforeWrite(DocumentWriteContext ctx, CancellationToken ct) { if (ctx.Operation != DocumentOperation.Delete) return;
// ctx.Store is transaction-bound, so this commits with whatever unit the Remove was part of. var updated = await ctx.Store.SetProperty<Order>(ctx.Id!, x => x.Status, "voided", null, ct); ctx.Cancel(updated); // no DELETE is issued; Remove() returns `updated` }
public Task AfterWrite(DocumentWriteContext ctx, CancellationToken ct) => Task.CompletedTask;}What cancelling does:
- No store write is issued for the operation, and no
AfterWritefires — nothing was written, so there is no change notification and no temporal history entry either (the write you made produces its own). - Later interceptors are skipped. The chain stops at the one that cancelled, so ordering matters —
a hook that must observe every delete needs a lower
Orderthan the one cancelling it. - The caller sees a normal result.
Cancel(bool succeeded = true)chooses whatRemovereturns — passfalsewhen your replacement write matched nothing.Insert/Update/Upsertreturn no value, so the flag is ignored there. - It is valid only inside
BeforeWrite— calling it fromAfterWritethrowsInvalidOperationException. - To fail a write rather than replace it, throw — cancelling is not an error path.
Set-based writes cancel the same way, with a count instead of a bool:
public sealed class SoftDeleteBulk : IDocumentBulkInterceptor{ public async Task BeforeBulkWrite(DocumentBulkContext ctx, CancellationToken ct) { if (ctx.Operation != DocumentOperation.Delete) return;
// The originating query — same predicate, same query filters — re-issued as an update. var affected = await ctx.QueryAs<Order>()!.ExecuteUpdate(x => x.IsDeleted, true, ct); ctx.Cancel(affected); // ExecuteDelete() returns `affected` }
public Task AfterBulkWrite(DocumentBulkContext ctx, CancellationToken ct) => Task.CompletedTask;}ctx.QueryAs<T>() is null for Clear (there is no source query) — use ctx.Store.Query<T>() there, which
covers every live document of the type.
Registering interceptors from DI
Section titled “Registering interceptors from DI”In addition to AddInterceptor / AddBulkInterceptor on the options, interceptors can be registered
in the service container — so they get constructor-injected dependencies. When you register the
store with AddDocumentStore, it resolves every IDocumentInterceptor and IDocumentBulkInterceptor
from the container and runs them alongside the options-registered ones.
public sealed class OutboxInterceptor(IOutbox outbox, ILogger<OutboxInterceptor> logger) : IDocumentInterceptor{ public Task BeforeWrite(DocumentWriteContext ctx, CancellationToken ct) => Task.CompletedTask;
public Task AfterWrite(DocumentWriteContext ctx, CancellationToken ct) => outbox.Enqueue(ctx.TypeName, ctx.Id, ctx.Operation, ct); // dependencies injected by DI}
services.AddSingleton<IOutbox, Outbox>();services.AddSingleton<IDocumentInterceptor, OutboxInterceptor>();
services.AddDocumentStore(opts =>{ opts.DatabaseProvider = new SqliteDatabaseProvider("Data Source=app.db"); opts.AddInterceptor(new AuditInterceptor()); // options-registered still works, runs first});Execution order is deterministic: options-registered interceptors run first, then DI-registered
ones (in container registration order). Override it with Order (see Ordering).
This applies to both per-document and bulk interceptors, and — since 11.0 — to every provider
(previously DI-registered interceptors silently never fired on MongoDB, Cosmos, LiteDB, IndexedDB,
DynamoDB, and Azure Table).
Scoped services inside a write (ctx.Services)
Section titled “Scoped services inside a write (ctx.Services)”An interceptor is a singleton, but a write often needs request-scoped services — the current user, a
scoped repository. Resolve them from ctx.Services inside the method, never the constructor. There is
no marker interface — any DI-registered interceptor gets a scope, and ctx.Services is never null:
public sealed class OrderValidationInterceptor : IDocumentInterceptor{ public int Order => 0;
public async Task BeforeWrite(DocumentWriteContext ctx, CancellationToken ct) { if (ctx.DocumentType != typeof(Order)) return;
// The scope the caller already holds — resolve scoped deps here, NOT via the ctor. var validator = ctx.Services.GetRequiredService<IOrderValidator>(); // scoped var user = ctx.Services.GetRequiredService<ICurrentUser>(); // scoped
await validator.EnsureCanPlace((Order)ctx.Document!, user.Id, ct); }
public Task AfterWrite(DocumentWriteContext ctx, CancellationToken ct) => Task.CompletedTask;}
services.AddScoped<IOrderValidator, OrderValidator>();services.AddScoped<ICurrentUser, HttpCurrentUser>();services.AddSingleton<IDocumentInterceptor, OrderValidationInterceptor>(); // stateless → singleton// A scoped interceptor is fine too now: services.AddScoped<IDocumentInterceptor, ...>();Interceptors are resolved fresh from the flowing scope per write, so a scoped interceptor resolves the
caller’s own instances (no captive-singleton problem — which is why the old scoped-interceptor ban and the
IScopedDocumentInterceptor marker are gone).
Where the scope comes from:
- Through an
IDocumentSession/DocumentContext— the session carries its own DI scope, soctx.Servicesis the caller’s own request scope (ASP.NET Core: inject the scopedIDocumentSession/context; elsewhere:IDocumentSessionFactory).IOrderValidator/ICurrentUserare the caller’s instances. - Through a raw singleton store immediate write (MAUI, a background worker, an Orleans grain) — there is no
ambient scope, so the unit-of-work engine opens one fresh child scope per unit when DI interceptors are
registered, and disposes it after. It is a new scope, not a request scope, so request identity must ride a
singleton ambient accessor — or open an
IDocumentSessionfrom a factory.
Transaction-visible reads and side effects (ctx.Session / ctx.Store)
Section titled “Transaction-visible reads and side effects (ctx.Session / ctx.Store)”ctx.Session is an IDocumentSession bound to the current write’s transaction — the
natural handle for re-entrant side effects. A single write that has per-document interceptors registered runs
as an implicit one-operation unit of work, so a hook can read and write through ctx.Session (or the
equivalent ctx.Store) on the same connection and transaction as the triggering write:
public async Task AfterWrite(DocumentWriteContext ctx, CancellationToken ct){ if (ctx.DocumentType != typeof(Order)) return;
// Flushes into THIS transaction; suppress so the outbox write doesn't re-enter this interceptor. using (ctx.Session.SuppressInterceptors()) { ctx.Session.Add(new OutboxEntry(ctx.Id!, "OrderPlaced")); await ctx.Session.SaveChanges(ct); } // Equivalent, immediate form: using (ctx.Store.SuppressInterceptors()) await ctx.Store.Insert(...);}On the relational providers and LiteDB, a hook sees this unit’s uncommitted rows (read-your-writes):
a BeforeWrite ctx.Store.Get(id) returns the prior row, an AfterWrite ctx.Store.Get(id) returns the
just-written row, and an AfterWrite side-effect write commits atomically with the triggering write (both
roll back together if the hook throws). Do this through ctx.Store, not a DI-resolved IDocumentStore:
the latter is the top-level singleton, which opens its own connection (not atomic, and a shared-connection
deadlock on in-memory SQLite/DuckDB). ctx.Store is never null and valid only within the hook — don’t
capture it past AfterWrite.
Other backends follow their own transaction model (ctx.Store is always safe to call): Cosmos is atomic
within one type/partition; MongoDB is fully transactional only on a replica set, committed-state otherwise;
IndexedDB / DynamoDB / Azure Table are committed-state reads only.
Ordering interceptors
Section titled “Ordering interceptors”Both IDocumentInterceptor and IDocumentBulkInterceptor expose int Order => 0. Lower runs first; ties
keep registration order (options-registered before DI-registered). Sequence, say, audit → soft-delete →
validation deterministically:
public sealed class AuditInterceptor : IDocumentInterceptor { public int Order => 10; /* … */ }public sealed class SoftDeleteInterceptor : IDocumentInterceptor { public int Order => 20; /* … */ }public sealed class ValidationInterceptor : IDocumentInterceptor { public int Order => 30; /* … */ }The serialized JSON about to be written
Section titled “The serialized JSON about to be written”Inside BeforeWrite, ctx.GetJson() returns the exact JSON that will be persisted for
ctx.Document — serialized with the store’s own options / JsonTypeInfo, computed on first access and
cached. If an earlier interceptor replaces ctx.Document, the cache is invalidated so a later
interceptor always sees the current document. ctx.GetJsonDocument() returns a parsed JsonDocument
(dispose it). Both return null for delete-by-id (no document).
public Task BeforeWrite(DocumentWriteContext ctx, CancellationToken ct){ var json = ctx.GetJson(); // e.g. {"id":"u1","name":"Alice","age":30} if (json != null && json.Contains("ssn")) throw new InvalidOperationException("PII must be redacted before persistence."); return Task.CompletedTask;}This is the primitive JSON Schema validation builds on — it’s generally useful for auditing, redaction checks, and capturing the outbound payload for a transactional outbox.
Suppressing interceptors for a transaction
Section titled “Suppressing interceptors for a transaction”Sometimes a write should carry no side effects — mirroring authoritative data in (offline sync inbound
applies), a bulk import, a seed, or a migration. Commit a session with
SaveChanges(suppressInterceptors: true) and no interceptor (per-document or bulk) fires for that
transaction:
await using var uow = store.OpenSession();foreach (var item in importedBatch) uow.Upsert(item);await uow.SaveChanges(suppressInterceptors: true); // no AfterWrite, no validation, no auditThe suppression is bounded by the commit — writes outside the unit still fire interceptors normally,
so it’s a scoped switch, not a store-wide toggle. While suppressed, the multi-row batch fast path is
re-enabled (it is only disabled to guarantee per-document interceptors fire — moot when none will). This
is exactly how Shiny.DocumentDb.AppDataSync applies pulled server changes without
echoing them back to the server.


