Skip to content

Below is the mental model I use when I want to understand Microsoft.Extensions.DependencyInjection as an engine, not just as a convenience API.

The short framing is this:

IServiceCollection is just a list of instructions. BuildServiceProvider() turns those instructions into a resolver engine. At runtime, the provider builds or reuses objects by walking a call-site graph, applying lifetime rules, and caching where appropriate. The default container keeps per-service accessors in a ConcurrentDictionary, has a root scope, and supports validation options during provider build. (GitHub)


PART 1 — CORE CONCEPTS RECAP

1) Inversion of control

At a low level, IoC means:

  • your class no longer decides how to build its collaborators
  • some external composition mechanism decides that instead
  • your class only declares what it needs

Without IoC, a class does this mentally:

“I need a logger, repository, cache, options object, and clock, so I’ll new them up.”

With IoC, the class says:

“I require these things in my constructor.”

That sounds small, but it changes the ownership model of the whole app. Object creation moves out of business code and into the composition layer.

The built-in .NET DI container is one concrete IoC mechanism. Microsoft’s docs explicitly describe DI as the technique used to achieve IoC between classes and their dependencies. (Microsoft Learn)

2) Dependency graph

A dependency graph is the tree or graph formed when one service needs other services.

Example:

  • OrderAppService

    • needs IOrderRepository

    • needs IPricingService

      • which needs IExchangeRateProvider
      • which needs HttpClient
    • needs ILogger<OrderAppService>

When the container resolves OrderAppService, it is not just creating one object. It is solving a graph.

This is the core reason DI containers exist. The problem is not “constructor injection is neat.” The real problem is: how do you construct and manage a whole graph consistently, efficiently, and safely?

3) Composition root

The composition root is the place where the graph is assembled.

This is one of the most important senior-level ideas in DI.

A composition root is where you:

  • register services
  • choose implementations
  • choose lifetimes
  • decide external configuration
  • build the provider

After that point, application code should mostly stop making container decisions.

In a well-structured app:

  • the composition root knows concrete types
  • the application layers mostly know abstractions

That separation is what keeps DI from turning into “service locator everywhere.”


PART 2 — SERVICE REGISTRATION INTERNALS

1) What IServiceCollection really is

IServiceCollection is not a magical container.

It is basically a mutable collection of ServiceDescriptor objects. Microsoft’s docs define IServiceCollection as the contract for a collection of service descriptors, and ServiceDescriptor as the object that describes the service type, implementation, and lifetime. (Microsoft Learn)

So before build time, this:

csharp
services.AddScoped<IOrderRepository, SqlOrderRepository>();

is conceptually just adding metadata like:

  • service type: IOrderRepository
  • implementation type: SqlOrderRepository
  • lifetime: Scoped

2) How descriptors are stored

Think of registration as appending records to a list.

A descriptor can describe an implementation in one of three common ways:

  • implementation type
  • implementation factory
  • implementation instance

That means the container is not storing ready-made “activation code” yet. At registration time, it is mostly storing declarative information.

3) How services are configured

At registration time, you are defining resolution rules, not creating the graph.

Examples:

csharp
services.AddSingleton<IClock, SystemClock>();
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddTransient<RetryPolicy>();
services.AddSingleton(sp => new ExpensiveClient("..."));

Those lines are closer to a build recipe than object creation.

Important consequence: most errors are not fully discovered at registration time. They are discovered later:

  • during provider build if validation is enabled
  • or at first resolution if validation is not enabled

That is why DI bugs often feel delayed.


PART 3 — SERVICE PROVIDER BUILD

1) What BuildServiceProvider() actually does

This is the big transition point.

Before build:

  • you have descriptors

After build:

  • you have a ServiceProvider instance
  • a root scope is created
  • a resolution engine is selected
  • call-site analysis machinery is prepared
  • service accessors are cached in a concurrent dictionary

The runtime source shows ServiceProvider holding:

  • CallSiteFactory
  • Root
  • _serviceAccessors as a ConcurrentDictionary<ServiceIdentifier, ServiceAccessor>
  • an engine used for resolution (GitHub)

That is the heart of the container.

2) How dependency graph analysis happens

The provider does not eagerly instantiate every service. Instead, it builds a system that can produce services on demand.

Internally, the important idea is the call site.

A call site is a compiled representation of:

“To create service X, use constructor C, and for each parameter resolve Y, Z, W, then apply caching rules according to lifetime.”

So the container analyzes registrations and turns them into an internal activation plan.

For a simple type registration:

csharp
services.AddScoped<IOrderRepository, SqlOrderRepository>();

the call site might roughly say:

  • service requested: IOrderRepository

  • implementation: SqlOrderRepository

  • constructor chosen: .ctor(DbConnection, ILogger<SqlOrderRepository>)

  • parameter call sites:

    • resolve DbConnection
    • resolve ILogger<SqlOrderRepository>
  • cache location: scope

3) How factories are created

The provider creates service accessors and resolver delegates around these call sites.

The exact engine can vary by runtime capability, but the design intent is:

  • analyze once
  • cache activation logic
  • make repeated resolution cheaper

That is why the first resolve can cost more than later resolves.

You should mentally model provider build as:

  1. index registrations
  2. prepare call-site generation
  3. prepare caching and validation
  4. resolve lazily, then cache activation/access paths

4) Validation at build time

The built-in provider supports validation options such as scope validation and validation on build. Microsoft’s docs also call out scope validation. (Microsoft Learn)

This is useful because some DI mistakes are structural, not runtime-business-logic bugs. For example:

  • singleton depending on scoped
  • impossible constructor graph
  • missing required registration

Not everything can always be validated upfront, but a lot can.


PART 4 — SERVICE RESOLUTION

1) How services are resolved at runtime

When code asks for a service:

csharp
var repo = provider.GetRequiredService<IOrderRepository>();

the provider roughly does this:

  1. identify the requested service
  2. look up or create a cached service accessor for that service
  3. use the accessor’s activation path
  4. create or reuse the instance depending on lifetime
  5. track disposables if needed

Because ServiceProvider stores service accessors in a concurrent dictionary, repeated lookups for the same service avoid redoing all analysis work. (GitHub)

2) Constructor injection flow

For constructor-based activation, the container:

  1. finds the implementation type
  2. selects a constructor
  3. resolves each constructor parameter
  4. invokes the constructor
  5. stores or returns the instance based on lifetime

Microsoft documents constructor selection behavior: when multiple constructors exist, the provider selects the constructor with the most parameters whose types are all DI-resolvable. (Microsoft Learn)

This is important because many real DI surprises come from constructor selection, not from registration itself.

3) Nested dependency creation

Suppose you resolve:

csharp
ReportService

and it depends on:

  • IReportRepository
  • PdfRenderer
  • ILogger<ReportService>

Then PdfRenderer depends on:

  • IFontCache
  • IImageScaler

Resolution becomes recursive:

  • resolve ReportService

    • resolve IReportRepository

    • resolve PdfRenderer

      • resolve IFontCache
      • resolve IImageScaler
    • resolve ILogger<ReportService>

The provider is basically walking the call-site tree depth-first, while honoring lifetime caching at each node.

4) Special case: ActivatorUtilities

ActivatorUtilities is related but slightly different. Microsoft documents it as helper functionality for creating objects that are not themselves registered, while still satisfying constructor dependencies from DI. (Microsoft Learn)

Mental model:

  • IServiceProvider resolves registered services
  • ActivatorUtilities can create an arbitrary concrete type using the provider for its dependencies

That matters in factories, UI composition, plug-in loading, and places where the type is known only at runtime.


PART 5 — SERVICE LIFETIMES INTERNALLY

This is where people move from “I know DI” to “I can debug DI.”

1) Singleton

A singleton is created once per root provider and reused thereafter.

Mental model:

  • cache location: root
  • owner: root provider
  • disposal: by the container when the root is disposed

Microsoft explicitly says that when a type or factory is registered as a singleton, the container disposes that singleton automatically. (Microsoft Learn)

2) Scoped

A scoped service is created once per scope.

Mental model:

  • cache location: current scope
  • owner: the scope
  • disposal: when the scope is disposed

In ASP.NET Core, scope usually maps to one HTTP request. In desktop apps, there is no automatic request boundary, so you must define the unit of work yourself.

3) Transient

A transient is created each time it is requested.

Mental model:

  • no reuse guarantee
  • activation cost paid repeatedly
  • disposal can still be container-managed if created by the container and tracked in scope/root

4) How instances are cached

Think in cache layers:

  • singleton → cache at root provider level
  • scoped → cache at scope level
  • transient → generally no reuse cache for instance identity

This is the most useful low-level view. Lifetime is fundamentally a statement about cache location and ownership boundary.


PART 6 — SCOPES & ROOT PROVIDER

1) Root provider vs child scopes

When the provider is built, the root scope is created immediately. The runtime source shows Root = new ServiceProviderEngineScope(this, isRootScope: true); during ServiceProvider construction. (GitHub)

So every resolution starts from one of two contexts:

  • the root scope
  • a created scope

A created scope is not a totally separate container. It is a different lifetime boundary over the same registration model.

2) Important subtlety: scopes are not hierarchical

Microsoft’s DI guidelines explicitly state that scopes are not hierarchical and there is no special connection among scopes. (Microsoft Learn)

This is a very important detail.

Many engineers assume:

  • root

    • scope A

      • child scope A1

That is not the model here. A scope is a sibling lifetime context, not a nested container with inheritance semantics like some third-party containers support.

3) Why scoped services are tricky in desktop apps

In web apps, the framework creates and disposes a scope per request. Easy.

In WPF, WinForms, MAUI desktop, services often live much longer and UI objects can stick around unpredictably. So the question becomes:

What is a scope?

Possible answers in desktop apps:

  • one window
  • one dialog
  • one document tab
  • one workflow run
  • one machine session
  • one command execution

If you do not answer that explicitly, scoped becomes fuzzy, and fuzzy lifetime boundaries create leaks and stale state.

This is why scoped services in desktop apps often go wrong:

  • window created from root provider
  • window keeps scoped dependencies forever
  • no one disposes the scope
  • objects linger much longer than intended

PART 7 — PERFORMANCE CHARACTERISTICS

1) Cost of resolving services

Resolution cost is usually a mix of:

  • lookup cost for the service accessor
  • constructor activation cost
  • nested dependency resolution cost
  • possible caching/disposable tracking cost

The provider reduces repeated overhead by caching service accessors in a concurrent dictionary rather than rebuilding everything on each resolve. (GitHub)

2) First resolve vs repeated resolve

A useful mental model:

  • first resolve of a given service type may be more expensive
  • later resolves are often cheaper because activation machinery is already prepared and cached

So the performance story is not “DI is free” and not “DI is terribly slow.” It is closer to:

setup and graph analysis cost are amortized; repeated steady-state resolution is usually acceptable for normal app architecture.

3) Deep dependency graphs

Deep graphs hurt in three ways:

  • more activation work
  • more opportunities for lifetime mismatch
  • more cognitive load when debugging

A graph depth of 2–4 is usually easy to reason about. A graph depth of 8–12 often signals architectural over-layering.

Even if raw performance is okay, maintainability starts degrading.

4) Transient-heavy graphs

This is a common hidden cost.

If you have:

  • many transients
  • expensive constructors
  • deep nesting
  • high-frequency resolution

then DI resolution can become a real hot-path cost.

Example:

  • every UI refresh resolves a presenter
  • presenter resolves 7 transients
  • some of those do reflection-heavy initialization

Now the problem is not “the container is slow.” The problem is your graph shape is activation-expensive.


PART 8 — COMMON LOW-LEVEL PITFALLS

1) Capturing scoped service in singleton

This is the classic bug.

Example:

csharp
services.AddSingleton<DashboardService>();
services.AddScoped<IUserContext, UserContext>();

If DashboardService depends directly on IUserContext, you are effectively trying to store a per-scope object inside a root-owned object.

Why this is wrong:

  • singleton lives for app/provider lifetime
  • scoped object lives only for one scope
  • the singleton may keep a stale or invalid reference

That is why scope validation exists and why this should be treated as a design bug, not just a registration mistake. Microsoft’s DI docs discuss scope validation, and the guidelines emphasize using scopes to control lifetimes. (Microsoft Learn)

2) Memory leaks due to root references

A very common desktop-app leak pattern is:

  • resolve something from the root provider
  • that thing transitively holds disposable/scoped/stateful objects
  • root never dies until app shutdown

Now the whole graph is effectively app-long.

Another version:

  • singleton subscribes to events from shorter-lived objects
  • references are never released
  • scope disposal does not help because the singleton still holds references

DI does not create this bug by itself, but it makes lifetime mistakes easier to globalize.

3) Circular dependencies

A depends on B, B depends on C, C depends on A.

The provider cannot construct such a graph through constructor injection. This is a structural failure.

Important point: circular dependencies are rarely “a DI problem.” They are usually a design smell indicating:

  • mixed responsibilities
  • orchestration and execution tangled together
  • bidirectional service knowledge

4) Overusing IServiceProvider

If every class takes IServiceProvider and resolves things manually, you have escaped the type system and reintroduced a hidden service locator.

Symptoms:

  • real dependencies become invisible
  • runtime failures replace compile-time clarity
  • testing gets harder
  • graph reasoning gets worse

Use it sparingly, usually at boundaries such as factories or plugin-style composition.


PART 9 — ADVANCED PATTERNS

1) Factory patterns

Factories are useful when creation depends on runtime data or timing.

Examples:

  • create a worker only when a machine connection is opened
  • create a window-specific workflow object
  • create one handler per file import job

Patterns you will see:

  • Func<IServiceProvider, T>
  • dedicated factory interfaces
  • ActivatorUtilities.CreateInstance(...)

Use a factory when the container cannot know everything at registration time.

A senior rule of thumb:

  • if creation depends only on registered services, constructor injection is enough
  • if creation depends on runtime values or specific timing, use a factory

2) Lazy injection

The built-in container is intentionally simple. It does not try to solve every deferred-creation pattern automatically.

Lazy-like behavior is commonly achieved through:

  • factory delegates
  • explicit factories
  • resolving a collection and choosing later
  • creating objects only when a workflow step begins

This is often better than injecting a ready-made expensive object that may never be used.

3) Open generics

The default container supports open generic registration such as:

csharp
services.AddScoped(typeof(IRepository<>), typeof(SqlRepository<>));

Then when IRepository<Order> is requested, the provider closes the generic over Order and builds the corresponding call site.

This is powerful because one registration can serve many closed constructed types.

But there are constraints. For example, a long-standing limitation is that open generic registration works with implementation types, while some other registration shapes are not supported the same way. There is an issue discussing that factory/instance-style open generic registration is not supported in the same manner. (GitHub)

So the mental model is:

  • open generics: yes
  • advanced generic tricks: limited compared with richer third-party containers

4) Collections

One of the most practical patterns is multiple registrations for the same service contract and then injecting IEnumerable<T>.

That lets you do strategies, handlers, enrichers, validators, and pipeline steps without custom container code.


PART 10 — SENIOR ENGINEER MENTAL MODEL

1) How to reason about object lifetimes

Do not think of lifetime as a registration keyword first.

Think of it as three questions:

  1. Who owns this object?
  2. How long should it live?
  3. Who disposes it?

Then map to DI:

  • app-long owner → singleton
  • operation/window/workflow owner → scoped
  • throwaway/stateless helper → transient

That is the cleanest mental model.

2) How to debug DI issues

When DI breaks, debug in this order:

First: identify the failing service type

  • what was requested?

Second: inspect its constructor

  • which constructor is being selected?
  • are all parameters resolvable?

Third: inspect lifetime edges

  • does a long-lived object depend on a shorter-lived one?

Fourth: inspect hidden runtime resolution

  • is a factory or IServiceProvider call hiding the real dependency chain?

Fifth: inspect scope ownership

  • who created the scope?
  • who disposes it?
  • is resolution accidentally happening from root?

That sequence solves most real DI bugs faster than staring at stack traces.

3) How to design maintainable dependency graphs

Good dependency graphs have these properties:

  • shallow enough to reason about
  • lifetimes aligned with ownership boundaries
  • constructors that express real dependencies, not convenience grabs
  • minimal hidden service location
  • clear composition root
  • limited use of container-specific tricks

A strong senior instinct is to notice when DI complexity is actually architecture complexity in disguise.

For example, if one service needs 11 dependencies, the problem is usually not “the container is annoying.” The problem is often one of these:

  • class doing too much
  • orchestration mixed with domain logic
  • infrastructure concerns leaking upward
  • missing sub-abstractions

4) What the built-in container is optimized for

Microsoft’s default container is deliberately pragmatic:

  • good integration with the .NET hosting ecosystem
  • predictable constructor injection model
  • lifetimes and scopes
  • open generic support
  • validation options
  • decent performance through caching and compiled activation paths

But it is not meant to be the most feature-rich IoC container in existence.

That is why some advanced features are intentionally limited compared with Autofac, Lamar, DryIoc, and others.

This is not a weakness by accident. It is a trade-off by design.

5) The best final mental model

Here is the most useful compact model:

  • IServiceCollection = recipe list
  • BuildServiceProvider() = compile the recipe system
  • call site = activation plan for one service
  • root/scope = lifetime cache boundary
  • singleton/scoped/transient = where instance reuse is stored
  • resolution = walk plan, create nested dependencies, reuse where allowed
  • DI bug = usually graph design bug, lifetime bug, or ownership bug

That is how I would explain it in a technical leadership interview.


A compact interview-quality summary

Microsoft.Extensions.DependencyInjection is fundamentally a two-phase system. In the first phase, you collect ServiceDescriptor records in IServiceCollection. In the second phase, BuildServiceProvider() creates a ServiceProvider with a root scope, call-site analysis, and cached service accessors. At runtime, the provider resolves services by following constructor-based call sites, recursively building nested dependencies and applying lifetime caching rules. Singleton instances are cached at the root, scoped instances per scope, and transients are recreated on demand. The hardest DI problems are usually not syntax problems; they are ownership and lifetime problems, especially when long-lived objects capture short-lived ones, or when desktop apps fail to define clear scope boundaries. (GitHub)

If you want, I can do the next step and turn this into a mock interview Q&A version with strong senior-level answers.

Docs-first project memory for AI-assisted implementation.