Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/everything-is-a-singleton && read --section="top" 0%
.NET

Everything Is a Singleton

Twenty-two registrations, zero scoped, zero transient - a lifetime decision that buys a genuinely fast request path and enables exactly three bugs, all of them the same mistake.

By Kumar Chandrachooda 19 Feb 2026 7 min read
Eight callers pointing inward at a single filled circle, one shared instance

Service lifetimes are the part of dependency injection that developers learn by being burned. Scoped is the default in ASP.NET Core because a request is the natural unit of work, and because the framework's own scope validation will tell you loudly when you inject a scoped service into a singleton. So the received wisdom is: use scoped unless you have a reason. Which is fine advice, and it quietly assumes that per-request state exists and needs somewhere to live.

Part 10 read the composition root's worst line. This part reads its most consequential one — or rather its twenty-two identical ones, because Ntrada took the opposite bet, and half of that bet paid off completely.

Twenty-two registrations, one lifetime

Count the descriptors in NtradaExtensions. There are twenty-two AddSingleton calls: nineteen in AddNtradaServices at lines 126–144, plus IOptionsProvider at line 62, NtradaOptions at line 64 and IExtensionProvider at line 153. There is not one AddScoped. There is not one AddTransient — the only match for that string in the file is AddTransientHttpErrorPolicy, which is Polly, not a lifetime.

services.AddSingleton<IAuthenticationManager, AuthenticationManager>();
services.AddSingleton<IAuthorizationManager, AuthorizationManager>();
services.AddSingleton<IPolicyManager, PolicyManager>();
services.AddSingleton<IDownstreamBuilder, DownstreamBuilder>();
services.AddSingleton<IPayloadBuilder, PayloadBuilder>();
// ... fourteen more
services.AddSingleton<DownstreamHandler>();
services.AddSingleton<ReturnValueHandler>();
services.AddSingleton<WebApiEndpointDefinitions>();

NtradaExtensions.cs:126-144

Nothing in the core is per-request, and that is not laziness — it is a design position with a mechanism behind it.

The good half

Per-request state in this gateway lives in exactly one place: an ExecutionData object constructed at RequestProcessor.cs:53-68 and passed down by argument.

var executionData = new ExecutionData
{
    RequestId = requestId,
    ResourceId = resourceId,
    TraceId = traceId,
    UserId = context.Request.HttpContext.User?.Identity?.Name,
    Claims = ...,
    ContentType = contentTypeValue,
    Route = routeConfig.Route,
    Context = context,
    Data = routeData,
    Downstream = _downstreamBuilder.GetDownstream(routeConfig, context.Request, routeData),
    Payload = payload?.Payload,
    HasPayload = hasTransformations
};

Three properties of that arrangement are worth stating positively, because they are the reason the singleton decision is defensible.

No per-request activation cost. A request through this gateway resolves nothing. There is no scope creation on the endpoint path beyond the framework's own, no call-site walking, no constructor invocation. For a component whose entire job is to add as little latency as possible between a caller and an origin, that is the correct optimisation, and it is the one Ntrada actually made rather than talked about.

HttpContext.RequestServices is never touched by the core. Not once in 3,200 lines. That is a discipline most codebases lose within a year, and it means there is no hidden service-locator path through the request.

The parameter object is the contract. Every collaborator on the request path — payload builder, transformer, validator, downstream builder, both handlers, all four hook interfaces — receives the same ExecutionData. When you want to know what a request carries, there is one class to read. Extension authors get the same object, which is why the RabbitMQ extension can build a message context from a request without reaching into HttpContext itself.

That combination — stateless singletons plus one explicit state object — is a coherent architecture, and it is more thought-through than “singletons are faster”.

The bad half, three times

The trouble is that a lifetime is not a licence, and three places in this codebase treat it as one. All three are the same mistake: something mutable is shared because the thing holding it happens to live forever.

One — hooks are resolved once, from the root provider.

public DownstreamHandler(IServiceProvider serviceProvider, IRequestProcessor requestProcessor, ...)
{
    ...
    _requestHooks = serviceProvider.GetServices<IRequestHook>();
    _responseHooks = serviceProvider.GetServices<IResponseHook>();
    _httpRequestHooks = serviceProvider.GetServices<IHttpRequestHook>();
    _httpResponseHooks = serviceProvider.GetServices<IHttpResponseHook>();
}

DownstreamHandler.cs:38-51

The handler is a singleton, so this constructor runs once, during UseNtrada, against the root provider. A hook registered AddScoped is therefore either silently promoted to a de-facto singleton, or — under CreateDefaultBuilder's scope validation in Development — crashes startup with Cannot resolve scoped service ... from root provider. Your hook cannot hold per-request state, cannot depend on anything scoped, and must be thread-safe. None of that is written down anywhere, and the hook interfaces are the library's advertised extension mechanism.

Notice also that this is a service-locator call four lines below a correctly injected dependency. IHttpClientFactory arrives on line 39 by constructor injection, as it should. The four hook collections could have arrived the same way — IEnumerable<IRequestHook> is a first-class constructor parameter in Microsoft.Extensions.DependencyInjection and has been since 1.0 — and the container would then have had the chance to complain about the lifetime mismatch. Injecting IServiceProvider is what silenced it. The prescription is to resolve hooks per request from context.RequestServices, or to inject the collections and let the container decide.

Two — the handler registry is static.

private static readonly ConcurrentDictionary<string, IHandler> Handlers =
    new ConcurrentDictionary<string, IHandler>();

RequestHandlerManager.cs:13-14

RequestHandlerManager is already a singleton. The static adds nothing except a second scope of sharing — process-wide rather than container-wide. In a single-gateway process that is invisible. In a test host that spins up two WebApplicationFactory instances in the same process, or in any future multi-tenant arrangement, handlers registered by one gateway are visible to the other, and TryAdd failing means the second gateway silently keeps the first one's handlers. The field is readonly and the dictionary is concurrent, so it is safe; it is simply sharing more than it meant to.

Three — two shared statics that should not be shared.

private static readonly IDictionary<string, string> EmptyClaims = new Dictionary<string, string>();

RequestProcessor.cs:14

A mutable Dictionary handed out as the Claims property of every unauthenticated request's ExecutionData. Nothing in the core writes to it — but ExecutionData.Claims is a public settable IDictionary on an object that is explicitly handed to third-party hooks and extensions. One hook doing data.Claims["tenant"] = ... as enrichment mutates the shared instance for every anonymous request in the process, forever. The fix is ImmutableDictionary<string, string>.Empty, or a fresh dictionary per request — this one allocates nothing, which is exactly why it was written this way.

private static readonly HttpContent EmptyContent =
    new StringContent("{}", Encoding.UTF8, ContentTypeApplicationJson);

DownstreamHandler.cs:26-27

The one that actually breaks. Part 4 traced how using var content = GetHttpContent(executionData) at line 198 disposes whatever it is given, including this shared static — so the second request that takes the empty-content path gets an ObjectDisposedException. A shared static and a using in different methods are individually reasonable and jointly fatal, and neither author of either line could have seen the other.

There is a fourth, benign case worth a sentence for completeness: ExtensionProvider._extensions is a non-readonly field lazily populated by a check-then-act in GetAll(). It is only exercised at startup, and a unit test pins the memoisation as intended behaviour, so it is correct in practice — but it is the same shape as the other three.

What the lifetime choice makes impossible

The other half of an honest accounting is what the decision forecloses, because two reasonable gateway features are structurally unavailable here.

Per-request caching. A scoped service is the natural home for “resolve this once per request and reuse it” — a decoded token, a tenant lookup, a downstream service address chosen by a load-balancing decision. With no scope, anything of that kind has to be a property on ExecutionData, which means it has to be a concern the core library already knows about. An extension cannot add one.

Per-request disposal. Nothing in the request path can own an IDisposable whose lifetime is the request, because there is no scope to dispose it. That is fine for a gateway that holds nothing — and it is the reason the one disposable in the path, the outbound HttpContent, is managed by a hand-written using in a method rather than by the container, which is exactly where part 4's shared-static bug came from.

Neither is an argument for scoping the whole graph. Both are arguments for the hooks — the one place where third-party code runs — resolving from context.RequestServices rather than from the root. That is a two-line change that keeps every benefit of the all-singleton core and removes the sharpest edge it has.

The rule this leaves you with

Ntrada's lifetime decision was right and its consequences were not managed. That is a more useful thing to learn from than a simple mistake, because the same trade is in front of you every time you build a request-scoped pipeline component.

Singleton is the correct lifetime for a component that has no per-request state — and choosing it obliges you to prove, for every field, that it has none. The three defects above are all the same failure of that proof: a collection resolved once, a dictionary shared once, a disposable shared once. None of them is about the lifetime keyword. All of them are about what got attached to it afterwards.

If you take one operational rule from this part: any static readonly field of a mutable or disposable type inside a request path is a design decision that needs a comment explaining why it is safe. Both of Ntrada's have no comment, and one of them is a bug.

Next, your API gateway cannot proxy an image — one response-shaping feature, and the buffering it imposed on every byte of traffic.