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),
and Version.
Semantics
Section titled “Semantics”BeforeWriteruns before serialization. Mutations toctx.Documentare persisted. Throwing aborts the write and propagates (there is no separate cancel flag).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), and AffectedCount (after only).
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). This applies to both per-document and bulk interceptors.
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 UnitOfWork with
SaveChanges(suppressInterceptors: true) and no interceptor (per-document or bulk) fires for that
transaction:
var uow = store.CreateUnitOfWork();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.