.NET Foundations

Lesson 1 of 5

Dependency Injection, Built in Miniature

"Ask, and it shall be given you; seek, and ye shall find." — Matthew 7:7, King James Version (1611)

A class with a constructor that takes interfaces is doing exactly that. It asks for what it needs and does not go looking for it, and somewhere else entirely, something decides what to give. That inversion — the class states requirements rather than satisfying them — is the whole idea, and a container is merely the clerk who keeps the ledger.

This Part steps away from threads and back to the platform. Dependency injection is first because it is the single most assumed piece of knowledge in .NET work: every ASP.NET Core application is built on it, every interview in this stack touches it, and most explanations begin with services.AddScoped<IThing, Thing>(), which teaches the incantation and not the mechanism. So we are going to write the container instead. It is about thirty lines, it does the real job, and once you have seen the recursion at the centre of it the official one stops being magic.

The big question

A service needs a store, which needs an audit log, which needs a clock. Written by hand that is four constructor calls in a specific order, and adding one dependency deep in the graph changes every place the graph is built. A container turns it into one line. What does it have to know to do that — and what did the hand-written version quietly decide that the container will decide differently?

Wiring by hand

Start without a container, because the costs it removes are only visible if you have paid them.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Four new calls in dependency order. Nothing here is wrong, and for a small program it is the right answer — explicit, debuggable, and with no framework involved.

Two things in that output matter for the rest of the chapter. The first is the cost of change: MemoryAuditLog needing an IClock is a decision taken inside one class, but it is paid by every place that constructs the graph. In a program with one composition site that is trivial; in one with forty tests each assembling their own, it is a day's work for a one-line change.

The second is more subtle and is the chapter's real hinge. The hand-written version passed the same IAuditLog instance to both the store and the service, which is why the count is 2 rather than two separate counters showing 1 each. Nobody decided that deliberately — it fell out of writing new MemoryAuditLog(clock) once and using the variable twice. A container will make that decision explicitly, and by default it will make it the other way.

And the third thing, which is the actual argument for all of this: the testing seam already exists. OrderService takes an IAuditLog, so a test can hand it one backed by a frozen clock, and the service cannot tell. That property comes from the constructor signature, not from any framework.

An analogy

A restaurant kitchen can work two ways. In the first, each cook fetches their own ingredients — walks to the market, chooses the tomatoes, comes back. Every recipe contains shopping instructions, and changing supplier means editing every recipe. In the second, cooks state what they need and a storekeeper hands it over; the recipes say "two tomatoes" and nothing about where tomatoes come from. The second arrangement is not obviously better in a two-person kitchen, and the storekeeper is pure overhead there. It wins at scale, and it wins immediately the moment somebody wants to cook the same dish with substitute ingredients — which is exactly what a test is.

A map and a recursive function

Here is the container. The interesting part is four lines long.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Registration is a Dictionary<Type, Type>. Resolution is: look up the requested type, find the implementation, read its constructor, resolve each parameter by calling yourself, and invoke. One line of recursion builds a graph of any depth, and Resolve<OrderService>() constructed four objects from a single request.

The constructor-selection rule is worth noticing because it is a real design decision, not an accident. Taking the greediest constructor means a class can offer a convenience constructor for hand-wiring and a full one for the container, and the container will prefer the full one. It also means adding a constructor to a class can silently change which one the container picks — a small trap the next block makes visible.

Everything a production container adds sits on top of these four steps: lifetimes, scopes, disposal, open generics, validation, and compiled resolvers that avoid reflection on the hot path. The shape does not change.

Now compare the object count with the previous block. Here OrderService depends only on IOrderStore, so the audit log is constructed once. Give it both dependencies, as the hand-written version had, and this container would construct two MemoryAuditLog objects — one for the store and one for the service — where the hand-written code shared one. Neither is obviously right, and choosing is the subject of the next chapter.

A note on history

The pattern is older than its name. "Inversion of control" was in circulation through the 1990s as a description of frameworks generally — the framework calls you, not the other way round — which was so broad that it explained very little. The sharpening came in 2004, when Martin Fowler wrote the article that proposed dependency injection as the narrower, more useful term for the specific case of supplying a component's collaborators from outside. The practice had already been established in the Java world by Spring, whose container did exactly what this chapter's thirty lines do, with XML instead of method calls. .NET arrived later and with more fragmentation: Castle Windsor, StructureMap, Autofac, Ninject and Unity all competed through the 2000s, each with its own registration syntax. The consolidation came with .NET Core in 2016, which shipped Microsoft.Extensions.DependencyInjection as a deliberately minimal container built into the platform — not the most capable of the field, but the one everything else could agree to target.

What it cannot check

Moving the wiring from code into a type map buys convenience and costs something precise: the compiler can no longer see whether the graph is complete.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Four failures, each needing deliberate work to report well.

The missing registration carries a trail: no registration for IEmail (needed by Outer → NeedsMissing). The type that is missing is rarely the type you asked for, so a container that reports only the leaf has told you the least useful half. Keeping a stack of what is currently being resolved costs one field and transforms the error.

The cycle is the one that must be handled rather than merely reported, because the naive implementation does not throw — it recurses until the stack overflows, which on many runtimes takes the process down rather than raising something catchable. The same stack that produces the trail detects it: if the type you are about to build is already in progress, you have found a loop.

The ambiguous constructor is a genuine judgement. Two constructors with one parameter each give the greediest-wins rule nothing to choose between, and guessing would be worse than failing. Real containers report this the same way.

The important sentence is the last one. All four are run-time errors. Wiring by hand, a missing dependency is a compile error you cannot ship; wiring by type, it is an exception on the first request that needs that path — possibly in production, possibly months later. That is why ValidateOnBuild exists in the real container, and why switching it on in development is close to free insurance.

From the notebook

The hinge of this chapter was not planned; it fell out of running the first block and reading the number. The audit count printed 2, and I had been about to write that the hand-wired version and the container version do the same thing more verbosely. They do not. Writing new MemoryAuditLog(clock) once and passing the variable to two constructors shares one instance; the container as written here builds a fresh one per injection point. Nobody decided the hand-written behaviour — it is a side effect of using a local variable twice — and that is exactly what makes it interesting, because the container forces the question into the open and makes you answer it with a lifetime. I had the whole of the next chapter sitting in a counter reading 2 instead of 1, and would have missed it by describing the code rather than running it.

Registrations are factories

Mapping a type to a type is the common case and the special case. Underneath, a registration is a function that produces an object.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Storing Func<Container, object> instead of Type makes three things fall out at once. Registering a type is a factory that builds it. Registering an existing instance is a factory that ignores its argument and returns the object. Registering a lambda is the factory itself — which is the escape hatch for anything the container cannot work out, such as a type with no suitable constructor or an object that needs configuration from elsewhere.

Keeping a list per service rather than a single entry gives two more behaviours that the real container shares. Resolving one service returns the last registration, so a later Register overrides an earlier one — the mechanism behind every TryAdd and every "replace this for testing". Resolving all of them returns every registration in order, which is how a chain of validators, handlers or middleware is wired without any of them knowing about the others.

The decorator at the end is the same idea pushed one step: resolve the existing registration, wrap it, register the wrapper. The consumer still asks for IGreeter and cannot tell that logging, caching or counting was inserted. This is where the indirection that looked like ceremony starts paying — a cross-cutting concern added in one place, with no change to either the implementation or its callers.

In the real world

The anti-pattern to recognise is the container turning up where it does not belong. Somebody needs a dependency in a class that is awkward to construct, so instead of adding a constructor parameter they take the container itself — or worse, reach for a static ServiceLocator.Current — and call Resolve inside the method that needs it. It works, and it undoes the entire benefit. The class's constructor no longer states its requirements, so you cannot tell what it needs without reading every method body; the compiler cannot help; and a test must now configure a container rather than pass two fakes. The honest signal is the dependency list: a constructor taking five interfaces is telling you the class does five things, which is useful information, and hiding them behind a service locator does not reduce the coupling, only the visibility of it. The rule worth holding is that the container should appear in exactly one place — the composition root, at startup — and that no other class should ever know it exists.

The payoff

All of the machinery exists for this: deciding, in one place, what the program is made of — and deciding differently for a test without touching the code under test.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

The production wiring would open a real connection. The test wiring records instead, and freezes the clock so the assertion can be exact rather than approximate — a date that is "about now" is the kind of thing that makes a test fail at midnight.

Look at what WelcomeService did to accommodate this: nothing. No test mode, no virtual method to override, no #if DEBUG, no subclass. It declared two requirements in its constructor and the decision about what satisfies them was taken elsewhere, which is the definition of the inversion.

That is also the honest answer to "do I need a container?" — no. You need the inversion, and the inversion is free: it is a constructor parameter. The container only removes the tedium of assembling the graph once the graph gets big. A small program with explicit wiring at the top is not missing anything, and a large one with a composition root and ValidateOnBuild has the same property with less typing.

Common Mistakes

  1. Injecting the container. A class that takes IServiceProvider and calls Resolve inside its methods has hidden its dependencies again. The constructor should state them.

  2. Using a static service locator. Same problem, worse: the dependency is now invisible and global, and tests must configure shared state.

  3. Expecting the compiler to catch a missing registration. It cannot. Wiring by type moves that error to run time, which is why ValidateOnBuild in development is worth switching on.

  4. Letting a cycle recurse. A naive resolver stack-overflows rather than throwing. Keep a stack of in-progress types and report the path.

  5. Adding a constructor to a class the container builds. Greediest-wins means a new, longer constructor silently becomes the one selected.

  6. Registering twice and expecting the first to win. Last registration wins for a single resolve; both are returned when resolving all of them.

  7. Spreading registration across the codebase. The container should appear once, at startup. Registrations scattered through modules make the program's composition impossible to read in one place.

  8. Interfaces created solely to enable injection. An interface with exactly one implementation, existing only so a container can map it, is ceremony — unless it is a genuine seam you substitute in tests.

  9. Forgetting that resolution is reflection. Building a graph per request costs real work; production containers compile their resolvers for this reason, and a hand-rolled one in a hot path will not.

  10. Assuming the container decides lifetimes sensibly by default. It does not decide for you at all; the default is usually a fresh object every time, which is rarely what a database connection or a cache wants.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
What is dependency injection, and what problem does it solve?
Model answer — It means a class declares what it needs — usually as constructor parameters, usually interfaces — instead of creating those things itself. Something outside decides what to supply. The problem it solves is coupling: if a service calls new SmtpEmailSender() inside itself, then it is permanently tied to that implementation and you cannot test it without sending real email. If it takes an IEmailSender, a test can pass one that records instead. A container automates assembling the object graph, but it is worth separating the two ideas — the injection is the design, and the container is just bookkeeping. You can do dependency injection with no framework at all by passing arguments to constructors.
Intermediate
How does a container know how to build a type it has never seen?
Model answer — By reflection and recursion. It looks the requested type up in its registrations to find an implementation, reads that implementation's constructors and picks one — conventionally the greediest it can satisfy — and then resolves each constructor parameter the same way, recursively, until it reaches types with no dependencies. Then it invokes the constructors on the way back out. That is genuinely all of it; the rest is policy on top: lifetimes, scopes, disposal, resolving collections of a service, open generics. The practical consequences of that design are worth knowing too. Because it works by type at run time, a missing registration is a run-time exception rather than a compile error, and a dependency cycle will recurse forever unless the container tracks what it is currently resolving. Production containers also compile their resolvers rather than reflecting on every request, because reflection per graph per request is not free.
Advanced
When is a container the wrong tool?
Model answer — When the program is small enough that explicit wiring at the top is clearer, which is more often than the ecosystem's habits suggest — a console tool or a small worker can assemble its graph in twenty readable lines and gets compile-time checking for free, which a container gives up. It is also wrong wherever it leaks past the composition root: injecting IServiceProvider, or a static locator, reintroduces hidden dependencies with extra indirection. And I would push back on interfaces that exist only to be registered — a one-implementation interface with no substitution in tests is ceremony, and the type is already a seam if you can pass a different instance. The judgement I would apply is whether the graph is big enough that assembling it by hand is genuinely costing something, and whether the registrations stay in one place. If they scatter, the program's composition stops being readable anywhere, and that is a real loss that the convenience does not always cover.
Company lens — how the same topic gets asked
micro1Direct: what dependency injection is, and why a constructor parameter beats a new.
GoogleThe mechanism — reflection, recursive resolution, and what the compiler stops checking.
MetaCode review: an injected IServiceProvider, or an interface with exactly one implementation.
AmazonOperational: a missing registration found by the first request in production, and how to fail at startup instead.

Hands-on

Hands-on

Write the three rules a container applies: what must be registered, how many objects a dependency chain produces, and when each kind of mistake is caught. The harness scores ten checks, each pairing a case that must be true with one that must be false. A correct solution prints score 10/10.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Run it unchanged first: ten failures and score 0/10. A stub that scores above zero means a check is not testing what it claims.

Summary

  • The inversion is the idea and the container is bookkeeping. A class that takes interfaces in its constructor already has the property; the container only assembles the graph.
  • Wiring by hand costs change, not correctness. Adding one dependency deep in a graph edits every place that graph is built.
  • A container is a Dictionary<Type, …> plus a recursive function: look up, read the constructor, resolve each parameter the same way, invoke.
  • Greediest-constructor-wins is a real decision, which means adding a constructor can silently change which one the container selects.
  • Moving wiring from code to types moves errors from compile time to run time — missing registrations, cycles and ambiguity are all found on resolve.
  • A cycle must be detected, not merely hit. Tracking in-progress types turns a stack overflow into an error naming the loop.
  • Report the trail, not the leaf. "No registration for IEmail (needed by Outer → NeedsMissing)" is the useful form.
  • A registration is really a factory, which is why registering a type, an existing instance, and a lambda are the same mechanism — and why decorators are easy.
  • Last registration wins for one resolve; all of them are returned when resolving a collection, which is how handler and validator pipelines are built.
  • The container belongs in exactly one place. Injecting it, or reaching for a static locator, hides the dependencies the constructor was supposed to declare.

In the next chapter, Lifetimes: Singleton, Scoped and Transient, we answer the question this one uncovered: the hand-wired graph shared one audit log and the container built a fresh one per injection point. Which is right, what each choice costs, and why the combination that looks most convenient — a long-lived object holding a short-lived one — is the single most common production bug in this stack.

Editorial reflection

It is worth being clear about which part of this chapter is the valuable one. Not the container — the real one is better in every respect, and nobody should ship this. The value is that thirty lines were enough to reproduce its behaviour, including its failure modes, which tells you the official container is not a large or mysterious thing. That matters because the alternative relationship with a framework is a list of incantations that work, and incantations do not degrade gracefully: when one fails you have nowhere to stand. Building a crude version of a tool you depend on is one of the cheapest ways to convert a list into a model, and it is usually far less work than it looks before you start.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.