Skip to main content

Advanced DI in .NET: Dependency Overrides, Decorators, Composites and Editable Pipelines

At Abblix, we develop Oidc.Server, our certified OpenID Connect and OAuth 2.0 library for .NET. Integrate it and a single line, AddOidcCore(), registers a few hundred services. One of them is a pipeline: a chain of validators that every token request runs through, folded into a single service you resolve. Now suppose your deployment has a rule the library's authors never anticipated. A validation step of your own has to run at an exact point in the chain, right after one the library already put there. You did not write the pipeline, you cannot edit the library, and the chain is already assembled behind one interface.

The package behind that escape hatch is Abblix.DependencyInjection, the DI toolkit Oidc.Server is built on. It takes no dependency beyond the Microsoft DI and logging abstractions. Everything it does rests on one observation you can confirm in a debugger: until you call BuildServiceProvider(), your DI configuration is not wiring or framework state. It is an IList<ServiceDescriptor> you can read, rewrite, and hand back.

TL;DR

Before BuildServiceProvider(), that configuration is still an editable list, and Abblix.DependencyInjection edits it. This article climbs from the simplest use of that to the most powerful. Aliases route one service through another's registration so both share an instance. Dependency.Override pins a single constructor dependency without marker interfaces or keyed-everything. Decorate wraps a service in place without a hand-written factory. Compose folds a family of implementations into one pipeline behind a single interface. And Decompose hands you a live cursor over that pipeline, so you can drop a built-in step or insert your own, in a vendor-composed pipeline, after the fact, any time before the container is built. Each rung uses only what the one below it established. It is all registration-time work, and it asks nothing of your classes beyond an interface.

What's here

The collection as data: aliases and queries

The smallest tools work directly on that list, and the alias is the one you meet first. It fixes a problem the built-in container handles badly: a class that implements two interfaces, registered the obvious way, becomes two objects.

public class InMemoryCache : IReadableCache, IWritableCache { /* ... */ }

services.AddSingleton<IReadableCache, InMemoryCache>();
services.AddSingleton<IWritableCache, InMemoryCache>(); // a SECOND InMemoryCache

Each registration activates its own instance, so IReadableCache and IWritableCache resolve to different objects, and whatever one of them caches is invisible to the other. The clean fix registers the class once as itself, then aliases each interface to it, so both resolve to the one instance and share its lifetime:

services.AddSingleton<InMemoryCache>();
services.AddAlias<IReadableCache, InMemoryCache>();
services.AddAlias<IWritableCache, InMemoryCache>(); // one instance behind both interfaces

That is how ClientInfoStorage in Oidc.Server registers once and serves two contracts, IClientInfoProvider and IClientInfoManager, from a single instance. TryAddEnumerableAlias does the same into an enumerable set with deduplication (the shape a grant handler uses for its two contracts), and AddKeyedAlias covers the keyed case. Next to them sits a drawer of one-liners that query or rewrite the collection directly: Find, FindAll, and FindRequired locate registrations, ChangeLifetime rewrites one in place, Clone copies a descriptor under a new service type, and RemoveAll prunes. None of them is clever, and that is the point.

Pinning one dependency out of many

Sooner or later one registration needs a different dependency than the global one. The textbook workarounds are all heavy: invent a marker interface, switch everything to keyed services, or give up on the container and new the graph by hand. Dependency.Override keeps the container in charge and pins only the parameters you name:

services.AddSingleton<WebhookPublisher>(Dependency.Override(signingSecret));

The container builds WebhookPublisher and resolves every constructor parameter as usual, except the one matching the pinned value, here a secret you hold at registration time that no container could resolve on its own. Overrides come in three flavors, each in generic and Type-based form:

Dependency.Override<IClock>(fixedClock)                           // pin an instance
Dependency.Override<IFetcher, CachingFetcher>() // pin a type to resolve for that parameter
Dependency.Override<IStore>(sp => sp.GetRequiredService<Redis>()) // pin a factory, run per construction

They work wherever the package builds objects: the AddSingleton/AddScoped/AddTransient overloads that take params Dependency[], the imperative CreateService<T> that builds an instance now and honors the same overrides, and the extra dependencies of a Decorate call. Under the hood it is ActivatorUtilities. The constructor is matched on each construction, pinned parameters skip resolution, the rest flow from the provider building the instance, so a scoped consumer still gets scoped dependencies.

Oidc.Server uses this where one consumer must see a differently wired dependency. The Pushed Authorization Requests handler needs a request-object fetcher, but not the global one the authorization endpoint uses. Its registration pins Dependency.Override<IAuthorizationRequestFetcher, RequestObjectFetchAdapter>(), giving the PAR handler just the request-object fetcher where every other consumer resolves the full multi-fetcher chain. The override lives on the one registration that is the exception.

Two habits keep overrides honest. Pin the exception and resolve the rule: if every registration overrides the same parameter, that parameter wants a real registration. And a pinned instance lives as long as the service holding it, so pinning something disposable into a singleton makes you its lifetime manager.

Wrapping a service you don't own

Aliases and overrides each leave the registration count alone. The next tool changes what sits behind one registration. Sometimes you need to add one concern to one service (cache its results, log calls, or guard what it hands back) without touching the class or knowing who registered it. That is the Decorator pattern, one of the Gang of Four structural patterns. A decorator implements the same interface as the object it wraps, holds a reference to it, and forwards calls while adding behavior before, after, or instead of delegating. Wrapper and wrapped are interchangeable to every caller, which is why decorators stack.

If you have decorated services in .NET before, you have probably used Scrutor, whose Decorate<TService, TDecorator>() has been the community's answer for years. Scrutor and this package share the same foundation: both build the decorator through ActivatorUtilities and swap the ServiceDescriptor in place, with no code generation and no dynamic proxies. They differ in scope. Scrutor's center of gravity is assembly scanning and decoration; it does not compose families or take them back apart, which is the whole reason this package exists. There was also a plainer motive for building rather than borrowing: Oidc.Server is a certified security library, and a third-party runtime dependency for machinery this central is surface we would rather not carry. Writing our own kept the footprint at the Microsoft abstractions and put decoration, composition, and editing in one place. If decoration is all you need, Scrutor does it well.

The heavier alternatives cost more than they save here. Adopting a full container like Autofac means taking on a second container and its registration model; your existing registrations keep working through Autofac's Microsoft.Extensions.DependencyInjection adapter, but Autofac now backs the whole graph. Editing through the DI collection keeps that reach with one change layered on the shipped package.

The call is one line:

services.Decorate<IJsonWebTokenValidator, TokenStatusValidatorDecorator>();

The decorator is an ordinary class that implements the interface and takes the wrapped instance as a constructor parameter. Everything else in its constructor still comes from the container:

public class TokenStatusValidatorDecorator(
ITokenRegistry tokenRegistry,
IJsonWebTokenValidator innerValidator) : IJsonWebTokenValidator
{
// validate via innerValidator, then reject tokens the registry marks revoked
}

Decorate finds the current registration and replaces its descriptor in place with one that builds the original and hands it to the decorator. In place has two consequences. The original lifetime is preserved, so decorating a scoped service gives a scoped decorated service. And the descriptor keeps its position in the collection, so decorating a member of an enumerable set neither reorders it nor detaches it from the set: a property that matters once those members are folded into a composite, as the next sections do.

Decoration stacks. Each call wraps whatever is registered right now, including a previous decorator, so the last one registered is the outermost at runtime:

services.AddScoped<IReportBuilder, ReportBuilder>();
services.Decorate<IReportBuilder, ValidatingReportBuilder>();
services.Decorate<IReportBuilder, CachingReportBuilder>();
services.Decorate<IReportBuilder, LoggingReportBuilder>();
// resolves to Logging( Caching( Validating( ReportBuilder ) ) )

Order is a design choice you make in the registration sequence, and it changes behavior. A cache outside the validator serves cached results without re-validating; swap the two lines and every hit re-validates. Logging outermost sees every call including cache hits; move it innermost and it sees only the misses.

Oidc.Server stacks two decorators on one service, added by two features that know nothing about each other. IAuthorizationRequestProcessor runs an authorization request after validation. The single-use-PAR decorator wraps it during endpoint wiring: once a request has minted a code or token, it consumes the pushed request_uri so it cannot be replayed (RFC 9126 §7.3). Session management wraps it later, in the feature-registration pass, attaching session_state on a successful sign-in for OIDC Session Management. Because AddOidcCore wires endpoints before features, the PAR decorator goes on first and ends up the inner layer, with session-state as the outer one. Neither feature coordinates with the other beyond the shared interface, and the code comment on the PAR side says as much: it mirrors the session-management decorator and stacks with it. That independence is what registration-order stacking is for.

DecorateKeyed<TInterface, TDecorator>(serviceKey) does the same for a keyed registration, with one deliberate fallback. If nothing is registered under the key but a plain registration exists, it builds a keyed decorated copy over the plain original and leaves the plain one untouched. Oidc.Server uses that to hand one consumer a caching ISecureHttpFetcher under a key while everyone else keeps the plain one. If there is nothing to decorate at all, the call throws and names the service, because a decorator waiting forever for a registration that never arrives is a bug better caught at startup.

When many services answer as one

Where a decorator wraps one service, a composite folds many into one. That is the Composite pattern, the sibling of the decorator among the Gang of Four structural patterns. The composite implements the same interface as its children, holds them, and answers each call by delegating across them. Written by hand it is tiny:

public class ValidatorComposite(IValidator[] validators) : IValidator
{
public bool Validate(Request request)
=> validators.All(validator => validator.Validate(request));
}

The consumer never asks for an IEnumerable; it keeps asking for the single service it always wanted and receives the whole chain disguised as one. AddOidcCore() composes exactly this. Before composing them, it registers the token-context validators as an ordinary family:

services.TryAddEnumerable([
ServiceDescriptor.Singleton<ITokenContextValidator, ResourceValidator>(),
ServiceDescriptor.Singleton<ITokenContextValidator, ClientValidator>(),
ServiceDescriptor.Singleton<ITokenContextValidator, ScopeValidator>(),
ServiceDescriptor.Singleton<ITokenContextValidator, AuthorizationGrantValidator>(),
ServiceDescriptor.Singleton<ITokenContextValidator, DPoPTokenEndpointValidator>(),
]);

Five validators, in an order that carries meaning: ClientValidator populates the client information that ScopeValidator and the DPoP check later read, so it has to run before them. TryAddEnumerable deduplicates on the (service type, implementation type) pair, so a feature module applied twice does not double a step.

Why the library hands you one service

The fair question first: why collapse five validators behind one service, instead of letting a consumer inject IEnumerable<ITokenContextValidator> and loop over it? That is the standard .NET pipeline pattern, it costs zero library code, and for many chains it is the right call. A composite earns its place when you want three things the raw enumerable does not give you. The iteration policy (run all of them, stop at the first failure, aggregate errors) lives in one named class instead of being copied into every consumer. The members are hidden from plain resolution, so nobody accidentally injects a half-assembled chain. And the whole pipeline becomes a single service you can decorate or replace as a unit. Oidc.Server wants all three, so it composes.

A plainer pattern than keyed registrations gets you most of the way: give the members their own IValidatorStep interface and write a composite that consumes them as IEnumerable<IValidatorStep> internally, so consumers still inject the single composite, never the enumerable. That delivers the first and third benefits in full, and the second only weakly: the members remain injectable under IValidatorStep, where keying hides them from resolution entirely. What it cannot give you is the thing the rest of this article rests on: a pipeline you can still edit after it is composed. (Keeping the members and the composite under one interface is what makes that editing possible; it is why the package keys members by composite type rather than splitting the interface.) If you never need to reopen the pipeline, the two-interface composite is simpler, and you should reach for it first.

In Oidc.Server the composite class is TokenContextValidatorComposite, and one call connects it to the family:

services.Compose<ITokenContextValidator, TokenContextValidatorComposite>();

The class is the easy half. The hard half is the wiring: getting the five members into that constructor, hiding them from every other consumer, and keeping them editable afterward. Compose transforms the collection to do all three. It re-registers the five members as keyed descriptors whose service key is the composite type itself, adds the composite, and points a plain ITokenContextValidator alias at it:

  • Before: five plain ITokenContextValidator descriptors.
  • After: the same five, re-registered under the key typeof(TokenContextValidatorComposite); one descriptor for the composite whose factory pulls those keyed members; and one plain ITokenContextValidator alias routing to the composite.

Keying the members by the composite type pays off three ways. Keyed registrations are invisible to plain resolution, so resolving ITokenContextValidator yields only the composite and every existing consumer keeps working. The members remain real descriptors in the collection, findable by anything that knows the key. And because the key is the composite type, the descriptors are their own registry, with no bookkeeping kept on the side.

At resolve time the composite's factory asks for GetKeyedServices<ITokenContextValidator>(key) and passes the members, in registration order, into the composite's T[] constructor. Descriptor order in the collection thus becomes execution order in the pipeline. That execution order is load-bearing, so it is the one platform assumption the package actively guards: the tests resolve a composed pipeline and fail if its members ever come back out of order. The platform documents ordering only for plain IEnumerable<T> resolution, so that guard is what makes relying on it safe.

This also closes a silent trap. Without the keyed move the five members would all be plain ITokenContextValidator registrations, and Microsoft's container resolves a singular service as last-wins. GetRequiredService<ITokenContextValidator>() would then hand back only the last-registered step and quietly skip the other four, with no error to warn you that you are validating with one check instead of five. Keying the members takes them out of plain resolution entirely, so a singular resolve can only find the composite. Plural resolution follows suit: GetServices<ITokenContextValidator>() returns exactly one element, the composite, so consumers that injected the enumerable keep compiling and simply see the whole chain as a single item.

The lifetime rule is asymmetric. The composite adopts the shortest lifetime among its members, and each member keeps its own. The shortest is the only safe choice for the composite: it must never outlive a member, since a singleton composite holding a scoped step would capture it. A member longer-lived than the composite is fine, though. A singleton member of a scoped pipeline is created once and shared across every scoped composite instance, never re-created per request. So a mixed-lifetime family composes cleanly: several built-in pipelines deliberately mix one scoped validator among singletons, and the singletons stay singletons.

Because the members are genuine descriptors now, the container inspects them like any other registration: with ValidateOnBuild enabled, a member whose own constructor dependencies can't be satisfied fails build-time validation with a precise error instead of surfacing only at first resolve. A holder that kept the members to itself would forfeit that, since the container can only validate what it can see.

Taking the pipeline apart

The pipeline is sealed behind one interface. To change a step after the fact, you have to reach its members again. That is what Decompose is for: a 2.4 addition; before 2.4 the same result means arranging the family's registrations before the composing call, by hand.

Decompose returns an IComposition<ITokenContextValidator>: a live IList<ServiceDescriptor> cursor over the family's keyed members. It does not detach them; every edit rewrites the keyed registrations in place, and the composite, rebuilt from its keyed members by a factory that reads them via GetKeyedServices, simply sees the new set. AddFirst and AddLast place a step unconditionally, while AddBefore<T>, AddAfter<T>, Remove<T>, and Replace<T> match their anchor by implementation type and throw when it is absent, so a mis-anchored edit fails at startup rather than doing nothing. Because these methods live on IComposition<TInterface> itself, the family interface is already bound: you name only the anchor at the call site (AddAfter<ScopeValidator>), never AddAfter<ITokenContextValidator, ScopeValidator>.

ResolveImplementationType, which the anchor matching leans on, has one limit. A descriptor registered by implementation type, by instance, through a typed AddX, or through the package's own helpers reports its type cleanly, including the typed-factory shapes the alias helpers produce (the type is recovered from the delegate's generic arguments, or from the compiler-generated wrapper when those are erased). The exception is a hand-written factory lambda: services.AddScoped<ITokenContextValidator>(sp => new MyValidator()) compiles to a delegate whose only visible type is the return type, so ResolveImplementationType reports the interface and a match by concrete type finds nothing. Match those by service type or by position instead.

Modifying the pipeline

Your first instinct is probably to append your own validator the way you would extend any ordinary family, after AddOidcCore():

services.AddOidcCore(/* ... */);
services.TryAddEnumerable(
ServiceDescriptor.Singleton<ITokenContextValidator, MyAuditValidator>());

It compiles, and it does nothing useful. By the time control returns from AddOidcCore(), those five validators are no longer resolved as an enumerable. They have been folded into a single composite behind ITokenContextValidator. A singular registration is last-wins, so your late addition either sits unused or shadows the whole composite, depending on how you registered it. The cursor from the previous section is the supported way to reach the composed pipeline.

An insertion names an anchor and the step to place after it:

// in your composition root, AFTER AddOidcCore()
services
.Decompose<ITokenContextValidator>()
.AddAfter<Token.Validation.ScopeValidator>(ServiceDescriptor.Singleton<ITokenContextValidator, MyAuditValidator>());

Two things about that snippet are easy to miss. It has to run after AddOidcCore(), because Decompose throws if the family was never composed, and composition happens inside AddOidcCore. That is the mirror image of the other extension path: registering your own validator before the library call still works, and editing the composed family works after it. The second is the anchor type. Several validator names are reused across endpoint families (the library defines four ResourceValidators, and more than one ScopeValidator), so qualify the namespace you mean; a wrong one matches no member, and the edit fails at startup rather than leaving the pipeline unchanged.

Removing a step reads the same way:

services.Decompose<ITokenContextValidator>().Remove<Token.Validation.ResourceValidator>();

As AddOidcCore composes it, the pipeline is the one that passed certification. Any edit, whether you insert, reorder, or drop a step, means you are running a pipeline the certification did not cover, and re-establishing conformance is on you. Reordering can break a spec requirement as surely as deleting, because the built-in order encodes correctness. So change as little as the fix needs.

Appending or replacing in place is the same shape:

services.Decompose<ITokenContextValidator>()
.AddLast(ServiceDescriptor.Singleton<ITokenContextValidator, MyAuditValidator>());

services.Decompose<ITokenContextValidator>()
.Replace<Token.Validation.ScopeValidator>(ServiceDescriptor.Singleton<ITokenContextValidator, MyScopeValidator>());

Compose refuses a few misuses outright. Composing an already-composed family throws, because the second composite would try to resolve the first as its own member. And the cursor throws if you add a member shorter-lived than the composite, the one lifetime combination it cannot hold without capturing it, while accepting an equal- or longer-lived member freely.

Several pipelines behind one interface

The keyed API mirrors the plain one for hosts that run several pipelines of one interface. A host may run one per channel, say, each under its own key:

services.AddKeyedSingleton<IDeliveryStep, RenderStep>("email");
services.AddKeyedSingleton<IDeliveryStep, SendStep>("email");
services.ComposeKeyed<IDeliveryStep, DeliveryPipeline>("email");

services.DecomposeKeyed<IDeliveryStep>("email")
.AddFirst(ServiceDescriptor.Singleton<IDeliveryStep, ThrottleStep>());

The one new piece is the member key. A keyed family cannot key its members by composite type alone, because two pipelines of one interface might share the composite class and their members must never mix. So members live under a ComposedFamilyKey, a small record pairing the original service key with the composite type. Equality is by value, isolation follows from it, and from any member you can still recover both which pipeline it belongs to and what composes it. ComposedFamilyKey is a detail the package manages; you never construct one.

Where this ends

The boundaries are deliberate. All of this is registration-time machinery: the edits end at BuildServiceProvider(), and nothing here mutates a running container. The shortest-lifetime rule from earlier still holds: the composite never outlives a member.

The costs are real and worth naming. Decoration and overrides build through ActivatorUtilities-style factories and reflection, which the container cannot statically analyze the way it analyzes a typed registration; the package is not annotated for trimming or NativeAOT, so validate it before a trimmed deployment. That reflection is a constructor match on each construction: negligible for singletons, small against the crypto and I/O of a real token request, but worth measuring if a decorated service sits in a hot loop. A failure through a composite reports a longer, factory-built stack than a typed registration would, and those factory layers are opaque in a debugger even though the descriptor list stays readable at build time. And the indirection only pays off when you need post-hoc editing; without that, the two-interface composite from earlier is simpler.

Inside Oidc.Server this package is behind every AddX() you call, and it is your escape hatch the moment a built-in behavior is close but not quite. It asks nothing of your classes beyond an interface, and nothing of a composite beyond a constructor that takes its members as a T[].

The next time the library hands you a pipeline you cannot edit, open it. Call Decompose on it and look at what comes back: your registrations, in order, ready to rewrite before the container is built. Start with the one pipeline that almost fits.