Skip to content
Client v5: BLE, BLE Hosting, HTTP, Jobs - Linux, MacOS, & Blazor Support! Full AOT, RX on BLE only & MANY other features! Power up!

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), and Version.

  • BeforeWrite runs before serialization. Mutations to ctx.Document are persisted. Throwing aborts the write and propagates (there is no separate cancel flag).
  • 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), and AffectedCount (after only).

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.

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