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)
CommandInitializedfires 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.CommandExecutingfires immediately before execution, with the fully bound command - an interceptor can readcommand.Parametersthere.CommandExecutedcarries the elapsedTimeSpan;CommandFailedcarries 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.CommandInitializedis 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/catchto map a provider exception to a domain type. The one gap is deferred/streaming execution, where the failure surfaces during enumeration of anIAsyncEnumerablerather than at the call site. - No interceptors for the in-memory provider.
InMemoryDataContexthas no command or connection, soAddInterceptoronly affects database-backed contexts. CommandInitializedand 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 raisesCommandInitializedonly 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.