Skip to main content
Version: Next

Operation Pipeline

The EntityManager<TEntity, TKey> runs every write operation (AddAsync, AddRangeAsync, UpdateAsync, RemoveAsync, RemoveRangeAsync, RestoreAsync, HardDeleteAsync, HardDeleteRangeAsync) through an extensible operation pipeline — an ordered chain of interceptors that can observe, transform, or short-circuit each write before it reaches the repository, and react to it after it succeeds.

Why a pipeline?

Before the pipeline, cross-cutting concerns on EntityManager were scattered:

  • Cache warming and eviction were hardwired inline in every CRUD method.
  • The only extension points were a handful of protected virtual hooks (OnAddingEntityAsync, OnUpdatingEntityAsync, ...) that run before the write, cannot short-circuit, and have no after-write slot.
  • RemoveRangeAsync had no hook at all.
  • Teams needing audit trails or tracing built bespoke decorators or subclassed the manager — neither approach composes when multiple concerns are involved.

The pipeline gives every cross-cutting concern — events, audit, OpenTelemetry, multi-tenancy — one ordered, testable extension point.

The interceptor contract

public interface IEntityManagerInterceptor<TEntity, TKey>
where TEntity : class
where TKey : notnull
{
ValueTask<IOperationResult?> PreWriteAsync(IEntityOperationContext<TEntity, TKey> context);

ValueTask PostWriteAsync(IEntityOperationContext<TEntity, TKey> context, IOperationResult result);
}

PreWriteAsync

Invoked before the repository write, for each interceptor in registration order.

  • Return null to continue the chain and proceed with the repository write.
  • Return a failed IOperationResult to short-circuit the chain — the repository write is skipped, all downstream interceptors' PreWriteAsync are skipped, and PostWriteAsync is not called for any interceptor. The failed result is returned to the caller.

The interceptor may mutate context.Entity — the mutated instance is the one forwarded to the repository and to subsequent interceptors.

PostWriteAsync

Invoked after a successful repository write, for each interceptor in registration order.

  • Not called when the repository write throws.
  • Not called when any interceptor short-circuits the chain in PreWriteAsync.
  • Receives the IOperationResult of the operation (success or not-changed).

Use this slot for event emission, cache warming, audit recording, tracing span closing, etc.

The operation context

Each write operation creates an IEntityOperationContext<TEntity, TKey> carrying:

PropertyDescription
KindThe EntityOperationKind (Create, Update, Remove, Restore, HardDelete)
EntityThe mutable entity — interceptors can transform it in PreWriteAsync
OriginalThe pre-image loaded from the repository (null on Create)
KeyThe entity key, or null if the entity has no valid key
ActorThe current user identifier from IUserAccessor<string>, or null
TimestampThe operation timestamp from ISystemTime
CancellationTokenThe cancellation token for the operation
ItemsA per-operation key/value bag for sharing data between interceptors or between pre/post steps

For range operations (AddRangeAsync, RemoveRangeAsync, HardDeleteRangeAsync), one context is created per entity in the batch, mirroring the per-entity On*Async hook behavior. A short-circuit on any entity aborts the entire batch.

Registration

Interceptors are resolved lazily from DI via IEnumerable<IEntityManagerInterceptor<TEntity, TKey>> — zero cost when none are registered.

Register interceptors through the EntityManagerBuilder:

services.AddRepositoryContext()
.AddRepository<PersonRepository>(repo => repo
.WithManagement(mgmt => mgmt
.WithInterceptor<AuditInterceptor>()
.WithInterceptor<ValidationInterceptor>()))
.UseInMemory();

Interceptors run in registration order. The WithInterceptor<T>() method scans the type's implemented IEntityManagerInterceptor<,> (or IEntityManagerInterceptor<>) interfaces and registers against the matching closed generic, mirroring the WithValidator<T>() pattern.

Coexistence with the On*Async hooks

The existing protected virtual hooks on EntityManager are preserved:

  • OnAddingEntityAsync — stamps CreatedAtUtc on IHaveTimeStamp entities
  • OnUpdatingEntityAsync — stamps UpdatedAtUtc on IHaveTimeStamp entities
  • OnRemovingEntityAsync — stamps soft-delete fields on ISoftDeletable entities
  • OnRestoringEntityAsync — clears soft-delete fields
  • OnHardRemovingEntityAsync — no-op by default, available for audit/purge logging overrides

A builtin OnHooksEntityInterceptor wraps these hooks and is always appended last in the chain, after any user-registered interceptors. This means:

  • Subclasses overriding OnAddingEntityAsync etc. keep working with no code change.
  • User interceptors run before the framework's timestamp/soft-delete stamping, so they can observe or transform the entity before stamping.
  • The pipeline and the hooks coexist cleanly.

Builtin CacheInterceptor

The cache concern is aligned to the pipeline through a builtin CacheInterceptor<TEntity, TKey> that replaces the former inline SetToCacheAsync / EvictAsync glue duplicated across the write methods of the manager.

When is it active?

The interceptor is only appended to the chain when an IEntityCache<TEntity> is registered in the dependency injection container. Not registering a cache means no cache side effects — the cache concern is removable for tests or custom cache strategies, without subclassing the manager.

It runs after the OnHooksEntityInterceptor, so the cache sees the entity as persisted (with timestamp / soft-delete stamping already applied). PreWriteAsync never short-circuits the chain: the cache is a write-path concern that fires only after a successful write.

What does it do in PostWriteAsync?

Operation kindCache action
Create, Update, RestoreRe-cache the written entity (IEntityCache<T>.SetAsync)
Remove (entity implements ISoftDeletable)Re-cache the soft-deleted entity (SetAsync)
Remove (entity does not implement ISoftDeletable)Evict the entity (IEntityCache<T>.RemoveAsync)
HardDeleteEvict the entity (RemoveAsync)

The action is only taken when the operation succeeded: a not-changed or failed result leaves the cache untouched, matching the former inline behavior that fired only after a successful repository write. Cache keys are generated through IEntityCacheKeyGenerator<TEntity> resolved from DI — when no generator is registered (or it returns an empty array of keys), the entity is not cached.

Failure resilience

Failures in the cache are logged and swallowed, so a cache outage never propagates to the caller of the write operation. This preserves the behavior of the former inline helpers.

Behavior change in RemoveRangeAsync

Before the alignment, RemoveRangeAsync always evicted every entity in the batch, even ISoftDeletable ones — inconsistent with RemoveAsync, which re-caches soft-deletable entities. The builtin CacheInterceptor now handles the batch per entity, so RemoveRangeAsync is aligned with RemoveAsync: soft-deletable entities in a range Remove are re-cached, while non-soft-deletable entities are evicted.

Read-through cache is unchanged

FindAsync's read-through GetOrSetByKeyAsync stays inline — read-path caching is out of scope for this feature.

Short-circuit example

An interceptor that rejects writes outside business hours:

public class BusinessHoursInterceptor<TEntity, TKey> : IEntityManagerInterceptor<TEntity, TKey>
where TEntity : class
where TKey : notnull
{
public ValueTask<IOperationResult?> PreWriteAsync(IEntityOperationContext<TEntity, TKey> context) {
if (context.Timestamp.Hour is < 9 or >= 18) {
return new(new OperationError("OUTSIDE_HOURS", "Write rejected: outside business hours"));
}

return default;
}

public ValueTask PostWriteAsync(IEntityOperationContext<TEntity, TKey> context, IOperationResult result)
=> ValueTask.CompletedTask;
}

Register it:

.WithInterceptor<BusinessHoursInterceptor<Person, string>>()

When a write is attempted outside business hours, the interceptor returns a failed IOperationResult, the repository write is skipped, and the caller receives the error result — no exception thrown.

Post-write example

An interceptor that logs successful writes:

public class WriteLoggerInterceptor<TEntity, TKey> : IEntityManagerInterceptor<TEntity, TKey>
where TEntity : class
where TKey : notnull
{
private readonly ILogger<WriteLoggerInterceptor<TEntity, TKey>> _logger;

public WriteLoggerInterceptor(ILogger<WriteLoggerInterceptor<TEntity, TKey>> logger) {
_logger = logger;
}

public ValueTask<IOperationResult?> PreWriteAsync(IEntityOperationContext<TEntity, TKey> context)
=> default;

public ValueTask PostWriteAsync(IEntityOperationContext<TEntity, TKey> context, IOperationResult result) {
_logger.LogInformation("{Kind} on {EntityType} by {Actor} at {Timestamp}", context.Kind, typeof(TEntity).Name, context.Actor, context.Timestamp);
return ValueTask.CompletedTask;
}
}

Single-key entities (EntityManager<TEntity>)

For entities managed through EntityManager<TEntity> (using object as the key type), implement IEntityManagerInterceptor<TEntity> (the single-key variant). The manager automatically wraps single-key interceptors and feeds them into the same pipeline.