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

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.