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.