Skip to content
Document DB v12 - Improved Interceptors with Soft Delete Integration, AI protections, & Admin UI with Aspire Integration!How!?

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 for Insert, BatchInsert (per item), Update, Upsert, and Remove.
  • Bulk / set-based (IDocumentBulkInterceptor) — fires once for ExecuteUpdate, ExecuteDelete, and Clear, which never materialize the affected documents.
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).

  • BeforeWrite runs before serialization. Mutations to ctx.Document are persisted. Throwing aborts the write and propagates; ctx.Cancel() instead replaces the write with your own.
  • AfterWrite runs after the core write succeeds, inside the transaction, before commit. If the write throws, AfterWrite does not fire.
  • Multiple interceptors run in registration order.
  • A Restore (temporal) write fires interceptors with Source == Temporal so you can distinguish it from a direct write — the internal history-table writes are not surfaced.

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.

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 AfterWrite fires — 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 Order than the one cancelling it.
  • The caller sees a normal result. Cancel(bool succeeded = true) chooses what Remove returns — pass false when your replacement write matched nothing. Insert/Update/Upsert return no value, so the flag is ignored there.
  • It is valid only inside BeforeWrite — calling it from AfterWrite throws InvalidOperationException.
  • 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.

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, so ctx.Services is the caller’s own request scope (ASP.NET Core: inject the scoped IDocumentSession/context; elsewhere: IDocumentSessionFactory). IOrderValidator / ICurrentUser are 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 IDocumentSession from 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.

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; /* … */ }

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 audit

The 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.