Table of Contents

Interceptors

Observe the command execution and connection lifecycle of a context without deriving from DataContext, and without a logger.

Prerequisites: Connections · Query reuse · Dependency injection.

Overview

An interceptor is a small object registered on a context that receives callbacks around the work the context performs. nextorm ships two of them:

Interface Observes Callbacks
IQueryInterceptor command execution CommandInitialized, CommandExecuting, CommandExecuted, CommandFailed
IConnectionInterceptor connection open ConnectionOpening, ConnectionOpened

A callback receives an event payload (CommandEventData / ConnectionEventData) plus the ADO.NET object involved (DbCommand / DbConnection). The events are raised by the context's execution and connection axes, not by the command: a DbCommand is a data structure and cannot log or profile itself.

Typical uses are timing and metrics, structured logging of the executed SQL, and provider-specific command options (a command timeout, for example).

Registering an interceptor

Register on the builder to apply an interceptor to every context the builder creates:

using NextORM.Core;

var builder = new DataContextBuilder()
    .UseSqlite("app.db")
    .AddInterceptor(new TimingInterceptor());

using var ctx = builder.CreateDataContext();

Interceptors run in registration order. Because the interfaces use default interface methods, an implementation only overrides the callbacks it cares about - the rest stay no-ops. The same registrations work through dependency injection:

services.AddNextOrmContext(o => o
    .UseSqlite(connectionString)
    .AddInterceptor(new TimingInterceptor()));

Registering an interceptor from dependency injection

When the container builds the DataContextBuilder (the options path), a type or generic registration makes the builder resolve the interceptor from its own scope:

services.AddScoped<TimingInterceptor>();

services.AddNextOrmContext(builder => builder
    .UseSqlite(connectionString)
    .AddInterceptor<TimingInterceptor>()); // or .AddInterceptor(typeof(TimingInterceptor))

AddInterceptor<T>() and AddInterceptor(Type) resolve the service with GetRequiredService once per context creation, from the scope that created the builder. Container lifetimes therefore apply unchanged: a scoped interceptor is reused within the scope and differs across scopes, a singleton is shared, and a transient is resolved per context. The container owns the interceptor and disposes it; the builder never disposes a resolved instance.

The resolved object is attached to every interceptor interface it implements, so a class implementing both IQueryInterceptor and IConnectionInterceptor is observed by both pipelines. The type must be a closed reference type assignable to one of the two interfaces: an open generic or a value-type Type is rejected immediately with ArgumentException, and a null argument with ArgumentNullException.

Registration is deduplicated by exact key - instance.GetType() for an instance, the supplied Type for a type entry - and the first registration wins, including when instance and type entries are mixed. Registering the same type twice therefore resolves and invokes it once.

A type registration needs a DI-backed builder. On a standalone new DataContextBuilder() the failure surfaces at CreateDataContext() as InvalidOperationException, and a service that is not registered fails with the container's own GetRequiredService error. In both cases the failure happens before the context factory runs, so no context is partially constructed.

The EF Core bridge (CreateNextOrmContext) builds its context from a standalone builder, so it has no captured provider: registering an interceptor by type there fails at context creation with the same InvalidOperationException. Register an instance instead (configure: builder => builder.AddInterceptor(new TimingInterceptor())).

A context created from the builder can add more interceptors for its own lifetime:

using var ctx = builder.CreateDataContext();
ctx.AddInterceptor(new AuditInterceptor());

AddInterceptor is available on DataContext, not on the IDataContext facade, so cast the context when you hold the interface.

A query interceptor

using System.Data.Common;
using NextORM.Core;

public sealed class TimingInterceptor : IQueryInterceptor
{
    public void CommandExecuting(CommandEventData eventData, DbCommand command)
        => Console.WriteLine($"executing: {eventData.Sql}");

    public void CommandExecuted(CommandEventData eventData, DbCommand command, TimeSpan elapsed)
        => Console.WriteLine($"executed in {elapsed.TotalMilliseconds:F1} ms");

    public void CommandFailed(CommandEventData eventData, DbCommand command, Exception exception)
        => Console.WriteLine($"failed: {exception.GetType().Name}");
}

The lifecycle of a buffered terminal (ToList, First, ExecuteScalar, ...), a streaming terminal (ToAsyncEnumerable) and a DML statement (Insert, Update, Delete, Merge, Truncate) is:

CommandInitialized -> CommandExecuting -> CommandExecuted
                                      \-> CommandFailed   (on a provider error)
  • CommandInitialized fires when a command has been created and its parameters are bound. For a query this happens once, when the plan is built (a plan-cache hit does not fire it again); for a DML statement it fires on every execution, because mutation commands are not cached.
  • CommandExecuting fires immediately before execution, with the fully bound command - an interceptor can read command.Parameters there.
  • CommandExecuted carries the elapsed TimeSpan; CommandFailed carries the provider exception, which is then rethrown unchanged.

A connection interceptor

public sealed class AuditConnectionInterceptor : IConnectionInterceptor
{
    public void ConnectionOpening(ConnectionEventData eventData)
        => Console.WriteLine($"opening {eventData.Connection.GetType().Name}");

    public void ConnectionOpened(ConnectionEventData eventData)
        => Console.WriteLine($"opened {eventData.Connection.DataSource}");
}

The connection is opened lazily and reused, so ConnectionOpening/ConnectionOpened fire only for the transition from closed to open - not on every query. Both the synchronous EnsureConnectionOpen and the asynchronous EnsureConnectionOpenAsync paths raise them.

Guarantees

  • Registration order is preserved; every interceptor of a type receives every event, in the order it was added, after the builder-configured ones.
  • Thread safety: interceptors must be thread-safe, because a context may execute concurrently. The interceptor list itself is a copy-on-write snapshot, so adding an interceptor while queries run is safe.
  • Zero cost when unused: with no interceptor registered, the execution path takes a branch and calls the provider directly - no event objects, timestamps, delegates or async state machines are allocated.

Limitations

  • Do not change CommandText. A query's command belongs to the cached plan and is reused across executions; rewriting its text would silently desynchronise the cache. CommandInitialized is intended for options that do not alter the SQL (a timeout, provider-specific flags).
  • Exception translation is out of scope. There is no exception interceptor: wrap the explicit terminal call (or the repository method) in a try/catch to map a provider exception to a domain type. The one gap is deferred/streaming execution, where the failure surfaces during enumeration of an IAsyncEnumerable rather than at the call site.
  • No interceptors for the in-memory provider. InMemoryDataContext has no command or connection, so AddInterceptor only affects database-backed contexts.
  • CommandInitialized and the shared plan cache. The plan cache is shared per context type and thread, so a query whose plan was already built by another context raises CommandInitialized only on that first build.

See also


Source: src/nextorm.core/Interceptors/IQueryInterceptor.cs, src/nextorm.core/Interceptors/IConnectionInterceptor.cs, src/nextorm.core/DataContext/QueryExecutor.cs, src/nextorm.core/DataContext/Infrastructure/DbConnectionManager.cs, tests/nextorm.sqlite.tests/Interceptors/InterceptorTests.cs.