← Open in the full interactive course (progress tracking, search & more)

Constructor injection in a five-class demo is easy. Here's what actually changes once your app has five hundred classes.

Lessons 124 and 125 taught you how to register a service, resolve it from the container, and choose the right lifetime — Transient, Scoped, or Singleton. That's the mechanics, and in a small app, mechanics are basically the whole story: one Program.cs, a dozen builder.Services.AddScoped<...>() lines, done.

Now picture a real production application: two hundred registered services, forty feature areas, a dozen developers who've never met each other touching the same Program.cs. Two things tend to go wrong at that scale, and neither is a lifetime bug:

// Problem 1 — Program.cs, 800 lines deep, nobody dares touch it
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IPaymentGateway, StripeGateway>();
builder.Services.AddScoped<IEmailSender, SendGridEmailSender>();
// ... 200 more lines, no structure, no way to tell what belongs to what feature

// Problem 2 — a class that LOOKS like DI, but isn't
public class ReportController(IServiceProvider serviceProvider) //  injecting the container itself
{
    public IActionResult Generate(string reportType)
    {
        var generator = serviceProvider.GetService(GetGeneratorType(reportType)); // resolved on-demand, hidden from the constructor
        return Ok(generator);
    }
}

Neither of these is a beginner mistake — they're exactly the kind of thing that creeps in gradually, one "just add it here" decision at a time, in a codebase that started out clean. This lesson is about recognizing both, and fixing both, before they define your architecture.

In this lesson, you'll learn the composition root — the one place your object graph should actually get wired together — the Service Locator anti-pattern and exactly why it defeats the purpose of DI even though it looks like DI, how to organize registration code for a large app using grouped extension methods, and how honest, visible constructor dependencies make testing large systems tractable.

What Is It?

The Simple Explanation

"DI at scale" isn't a different technology from what Lessons 124–125 taught — it's the same constructor injection and the same container, disciplined by a few extra rules that only start to matter once the number of services and the number of developers both grow large: where wiring is allowed to happen, how registration code stays organized, and what counts as genuinely receiving a dependency versus just pretending to.

The Technical Definition

The composition root is the single, specific location in an application — typically near startup, in ASP.NET Core that's Program.cs and the extension methods it calls — where the object graph is actually assembled: every services.Add...() call, every lifetime decision, all in one traceable place. Everywhere else in the application should receive already-constructed dependencies through its constructor and never talk to the container directly.

The Service Locator anti-pattern is what happens when a class is given the container itself (IServiceProvider, or a hand-rolled wrapper around it) as a dependency, and then calls something like GetService<T>() on it from inside a method, on demand, whenever it needs something. It resembles DI — there's a container, there's an interface — but it is functionally the opposite: the class's real dependencies are hidden inside its method bodies instead of declared honestly in its constructor.

Why Does It Exist?

The Problem — Small-App Habits Don't Scale

In a five-class app, it genuinely doesn't matter much where you call services.AddScoped<T>(), or whether one class happens to reach into the container directly instead of taking a constructor parameter — the whole graph fits in your head at once. At two hundred services and a dozen contributors, neither assumption holds. Registration code sprawls with no organizing structure, so nobody can find where a given service is wired or confirm it's even registered without running the app. And once one class starts resolving dependencies on demand from an injected container instead of declaring them, that habit spreads — it's the path of least resistance for "I just need one more thing in here real quick" — until constructor signatures across the codebase stop telling the truth about what a class actually needs.

The Solution — A Disciplined Composition Root, and a Hard Line Against Service Locator

Concentrating all wiring into one composition root (organized into feature-grouped extension methods, so it stays navigable at any scale) and refusing to let any class reach into the container directly keeps the two properties from Lesson 237 intact even as the app grows: high cohesion (each registration group is organized by feature) and low coupling (every class's real dependencies stay visible and abstract, declared honestly at its constructor boundary).

Big Picture

WHO IS ALLOWED TO TOUCH THE CONTAINER
Composition Root
Program.cs + AddPaymentServices() etc.
The ONE place that calls services.Add...()
Container access: allowed
Everywhere Else
OrderService, ReportController, all of it
Receives dependencies via constructor only
Container access: never

How It Works

The Service Locator Anti-Pattern — Side by Side

Anti-pattern — the container itself is injected, dependencies hide inside methods:

public class OrderService(IServiceProvider serviceProvider)
{
    public async Task PlaceOrderAsync(Order order)
    {
        // Nothing in the constructor signature reveals ANY of this:
        var repository = serviceProvider.GetRequiredService<IOrderRepository>();
        var gateway = serviceProvider.GetRequiredService<IPaymentGateway>();

        await gateway.ChargeAsync(order.Total, order.CustomerId);
        await repository.SaveAsync(order);

        if (order.Total > 1000)
        {
            // A THIRD dependency, resolved conditionally, deep inside a method —
            // invisible unless you read every line of every branch.
            var fraudChecker = serviceProvider.GetRequiredService<IFraudChecker>();
            await fraudChecker.FlagForReviewAsync(order);
        }
    }
}

Why this looks like DI but isn't: OrderService technically receives something through its constructor — but that something is the entire container, not a real, honest dependency. Its real dependencies (IOrderRepository, IPaymentGateway, and conditionally IFraudChecker) are invisible from the outside; you'd have to read every line of PlaceOrderAsync, including every conditional branch, to know what this class actually needs to function.

Fix — real dependencies, declared honestly in the constructor:

public class OrderService(
    IOrderRepository repository,
    IPaymentGateway gateway,
    IFraudChecker fraudChecker) // every real dependency is right here — nothing hidden
{
    public async Task PlaceOrderAsync(Order order)
    {
        await gateway.ChargeAsync(order.Total, order.CustomerId);
        await repository.SaveAsync(order);

        if (order.Total > 1000)
            await fraudChecker.FlagForReviewAsync(order);
    }
}

Now the constructor signature is the honest, complete list of what OrderService needs to do its job — no reading method bodies required. That's the entire point of DI, and it's exactly what Service Locator throws away while superficially looking the same.

The Composition Root — Concretely

In an ASP.NET Core app, the composition root is Program.cs (plus whatever extension methods it calls) — the one place, close to Main, where the object graph actually gets assembled:

// Program.cs — the composition root
var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddPaymentServices(builder.Configuration)
    .AddNotificationServices(builder.Configuration)
    .AddOrderingServices()
    .AddPersistenceServices(builder.Configuration);

var app = builder.Build();
app.Run();

Nothing outside this file — no controller, no service, no background job — should ever call services.AddScoped<...>() or reach into a container to resolve something on demand. Everything downstream just receives what it needs, already built, through its constructor.

Simple Example — Grouped Registration Extension Methods

Instead of one giant Program.cs, each feature area owns its own registration method:

// PaymentServiceCollectionExtensions.cs — lives next to the payment feature's code
public static class PaymentServiceCollectionExtensions
{
    public static IServiceCollection AddPaymentServices(this IServiceCollection services, IConfiguration config)
    {
        services.AddScoped<IPaymentGateway, StripeGateway>();
        services.AddScoped<IFraudChecker, DefaultFraudChecker>();
        services.Configure<StripeOptions>(config.GetSection("Stripe"));
        return services;
    }
}

// NotificationServiceCollectionExtensions.cs — lives next to the notification feature's code
public static class NotificationServiceCollectionExtensions
{
    public static IServiceCollection AddNotificationServices(this IServiceCollection services, IConfiguration config)
    {
        services.AddScoped<IEmailSender, SendGridEmailSender>();
        services.AddScoped<ISmsSender, TwilioSmsSender>();
        return services;
    }
}

Each method lives physically near the code it registers — a developer working on payments finds AddPaymentServices() right next to StripeGateway, not eight hundred lines into an unrelated Program.cs. Program.cs itself shrinks to a short, readable table of contents.

Real-World Example — Why This Makes Testing Tractable

Picture testing the Service Locator version of OrderService from earlier. A unit test has to build a real (or heavily mocked) IServiceProvider capable of resolving every type OrderService might ask for at runtime — including the conditional IFraudChecker that only shows up for orders over $1000, which the test writer may not even know about without reading the whole method body first.

//  Testing the Service Locator version — fragile, and hides what's actually needed
var services = new ServiceCollection();
services.AddSingleton(mockRepository.Object);
services.AddSingleton(mockGateway.Object);
services.AddSingleton(mockFraudChecker.Object); // only known by reading every branch of PlaceOrderAsync
var provider = services.BuildServiceProvider();
var sut = new OrderService(provider);
//  Testing the honest constructor-injected version — the signature IS the test setup checklist
var sut = new OrderService(mockRepository.Object, mockGateway.Object, mockFraudChecker.Object);
// No container needed at all. Missing a dependency is a compiler error, not a runtime surprise.

With honest constructor injection, the compiler itself enforces that a test supplies every real dependency — miss one and the code doesn't compile. With Service Locator, a missing registration is a runtime failure, possibly one that only shows up for a conditional path a test never happened to exercise. At the scale of hundreds of services and thousands of tests, that difference between "compiler catches it" and "hope you wrote a test for that exact branch" is the difference between a maintainable test suite and a fragile one.

Analogy

A Kitchen's Pantry vs. a Grocery Store Membership Card

Honest constructor injection is a chef given exactly the ingredients a recipe calls for, laid out on the counter before cooking starts — flour, eggs, butter, listed on the recipe card itself. Anyone reading the card knows precisely what's needed.

Service Locator is handing that same chef a membership card to the grocery store instead, and telling them to "just go grab whatever you need, whenever you need it, mid-recipe." Technically they can still make the dish. But now nobody reading the recipe card can tell what ingredients it actually requires — you'd have to watch the chef cook the entire meal, live, to find out. And if the store happens to be out of butter that day, you don't find out until the chef is already halfway through baking.

The composition root is the one place — the grocery run itself, done once, in advance — where all the ingredients for the whole kitchen get gathered and organized into labeled bins by category (produce, dairy, spices — exactly like grouped registration extension methods). Every recipe after that just reads its labeled ingredients off the counter. Nobody with a recipe card ever needs the store membership card again.

Under the Hood — The Design Reasoning

WHY CONFINING THE CONTAINER TO ONE PLACE ACTUALLY MATTERS
1. VISIBILITY OF DEPENDENCIES
2. FAILURE TIMING
3. WHY ONE COMPOSITION ROOT, NOT MANY

Common Confusion

1. "The container is injected, so this must be DI" — the mechanism looks similar, the effect is opposite

DI's whole value proposition is that a class's dependencies are declared, visible, and provided from outside. Injecting the container and calling GetService<T>() inside a method keeps the dependencies hidden and resolved on demand — it uses a DI container, but it isn't practicing dependency injection in any meaningful sense.

2. "One composition root" doesn't mean "one giant file"

The composition root is a concept — the one logical place responsibility for wiring lives — not a mandate that all wiring code be physically crammed into a single Program.cs file. Grouped extension methods, called from that one file, keep both properties: one logical root, organized into readable, feature-scoped pieces.

3. Factories and Options aren't Service Locator

Injecting a well-typed factory (Func<PaymentProvider, IPaymentGateway>) or a bound IOptions<T> is not the anti-pattern — those are still specific, declared, honest dependencies. The anti-pattern is specifically injecting the generic, untyped container itself and pulling arbitrary services out of it by type at will.

Common Mistakes

Mistake 1 — "Just inject IServiceProvider, it's simpler" as a shortcut under deadline pressure

It genuinely is faster to type in the moment — one parameter instead of three or four. That short-term convenience is exactly how the anti-pattern spreads through a codebase, one rushed feature at a time.

Take the extra thirty seconds to list the real dependencies in the constructor. The cost is paid once, at write time; the Service Locator alternative charges that same cost back, with interest, at every future debugging session and every future test.

Mistake 2 — Registration code scattered across multiple unrelated files with no organizing principle

Some services registered directly in Program.cs, others in a Startup2.cs ("just to keep the file shorter"), others in a static constructor somewhere in the domain layer — three different places to check before you can be sure a service is even registered.

Group by feature area with clearly named extension methods (AddPaymentServices, AddNotificationServices), all called from the single composition root — organized without being scattered.

Mistake 3 — A "helper" static class that wraps the container and gets called from everywhere

public static class ServiceLocatorHelper
{
    public static IServiceProvider? Provider { get; set; } //  global, mutable, and invites resolving anything from anywhere
    public static T Resolve<T>() where T : notnull => Provider!.GetRequiredService<T>();
}

// Called from deep inside unrelated code, anywhere, at any time:
var logger = ServiceLocatorHelper.Resolve<ILogger>(); //  this is Service Locator wearing a static-class disguise

Wrapping the container in a static helper doesn't fix the anti-pattern — it makes it worse, because now any code, anywhere in the codebase, with no constructor dependency at all, can silently reach into the container.

There is no fix here except removing it — replace every call site with an honest constructor-injected dependency.

When Should I Use It?

Mental Model

Composition root = the ONE place the object graph gets built.
Everywhere else = receive, don't reach.
Service Locator = a dependency hidden inside a method, instead of declared in the constructor.

The one-question test: can you list everything this class needs just by reading its constructor signature — no method bodies required? If not, something is hiding.

Key Takeaway


Check Your Understanding

You've seen how DI discipline changes once an app grows past a handful of classes. Let's check the distinctions hold.

1. A class's constructor takes IServiceProvider, and a method inside it calls serviceProvider.GetRequiredService<IEmailSender>(). What is the core problem with this design?

Show answer

Correct: B

Why B is correct: This is exactly the Service Locator anti-pattern — the container is injected, but the real dependency (IEmailSender) is resolved on demand inside a method, invisible from the constructor, and only discoverable by reading the method body.

Why A is incorrect: Receiving the container itself is not the same as receiving a real dependency — DI's value comes from declaring actual dependencies, not from technically having "something" injected.

Why C is incorrect: IServiceProvider can be injected — the problem isn't that it's disallowed, it's what doing so and then calling GetRequiredService inside a method represents architecturally.

Why D is incorrect: Nothing here describes a lifetime mismatch (covered in Lesson 125) — the issue is about dependency visibility and resolution timing, not lifetime configuration.

Reinforcement: The test for genuine DI is whether a class's real dependencies are visible in its constructor — not whether a container object happens to be involved somewhere.

2. In a large ASP.NET Core application, where should the composition root be?

Show answer

Correct: C

Why C is correct: The composition root is a single logical location — near application startup — even when its implementation is organized into multiple grouped extension methods for readability. This keeps "what does the object graph look like" answerable from one traceable path.

Why A is incorrect: Spreading registration across every class removes any single place to audit the full object graph, and typically means classes registering dependencies for themselves — a sign of Service Locator creeping in, not a composition root.

Why B is incorrect: A repository class is a consumer of dependencies (like a database connection), not the place responsible for wiring the object graph — mixing the two defeats the separation the composition root exists to provide.

Why D is incorrect: Base controllers are part of the application's runtime behavior, not the startup wiring phase — putting registration logic there conflates two very different concerns.

Reinforcement: "One composition root" describes a single logical responsibility, not a literal single file — grouped extension methods keep it both centralized and organized.

3. Why does honest constructor injection make unit testing large systems more tractable than the Service Locator pattern, according to this lesson?

Show answer

Correct: B

Why B is correct: This is the direct payoff shown in the lesson's OrderService testing example — with honest constructor injection, the compiler enforces that every real dependency is supplied; with Service Locator, a missing or wrongly-mocked dependency only surfaces when that specific runtime code path executes.

Why A is incorrect: This lesson is about design-time and test-time tractability, not runtime performance — the container resolution overhead is not the point being made.

Why C is incorrect: Service Locator can technically be tested by building a real or mocked IServiceProvider — it's not impossible, just significantly more fragile and less discoverable than direct constructor injection.

Why D is incorrect: Neither approach generates tests automatically — this is about how much friction and hidden risk exists when writing tests by hand.

Reinforcement: Visible, compiler-checked dependencies turn "did I remember every dependency?" from a runtime gamble into a compile-time certainty.

4. A developer argues: "Injecting IServiceProvider is fine because it's still technically dependency injection — something is still being injected into the constructor." What is the strongest counterargument from this lesson?

Show answer

Correct: B

Why B is correct: This is the lesson's central distinction — DI's benefit comes from the constructor being an honest, complete declaration of what a class needs. Injecting the generic container satisfies the letter of "something is injected" while completely defeating that actual purpose, since the real dependencies remain hidden until you read the method bodies.

Why A is incorrect: IServiceProvider is a perfectly legal, commonly available public interface — it's not restricted from injection; the concern raised is architectural, not a technical restriction.

Why C is incorrect: There is a very meaningful difference, demonstrated throughout the lesson — visibility of real dependencies, timing of failures, and testability all diverge sharply between the two approaches.

Why D is incorrect: This isn't a compile-time restriction at all — the code compiles fine either way; the problem is a design and maintainability one, not a syntax error.

Reinforcement: The key question is never "is something in the constructor?" — it's "are the class's real dependencies honest and visible there?"

You now know what changes about dependency injection once a real application grows past a handful of classes — and can spot Service Locator hiding in plain sight.


dotnetmadeeasy.com — Learn C# and .NET, the right way.