For a long time, my mental model of Dependency Injection looked like this: create an interface, inject it through the constructor, and now the code is loosely coupled. It's not wrong, exactly, but it's about as useful as saying "cooking is using heat to change food." Technically accurate, completely inadequate.
What I was missing was the why behind the pattern, the design forces it responds to, and the architectural decisions it enables. After studying the first six chapters of Dependency Injection: Principles, Practices, and Patterns by Mark Seemann and Steven van Deursen — two of the clearest thinkers on the topic — my understanding of DI changed significantly. This article represents what I learned, organized in the way that made the most sense to me. It is not a summary of the book; it is an attempt to explain the ideas in the order and framing that clarifies them best.
What Is a Dependency, Really?
Before talking about how to inject dependencies, it's worth being precise about what a dependency actually is.
A dependency is anything a class needs in order to do its job. When ProductService queries a database to retrieve products, the database connection is a dependency. When it applies a discount based on the current user's role, the user context is a dependency. When it converts prices to another currency, the currency converter is a dependency.
In code, dependencies tend to show up in one of two forms: either they appear in constructor parameters (visible), or they are created with new somewhere inside the class body (hidden). The difference matters enormously.
Stable vs. Volatile Dependencies
Not every dependency is worth abstracting over. The book makes an important distinction between stable and volatile dependencies.
Stable dependencies are deterministic, backward-compatible, and don't cross out-of-process boundaries. The .NET BCL types — string, List<T>, Math — are stable. You can safely use new for those and nobody is harmed.
Volatile dependencies are a different story. Anything that:
- talks to a database, file system, or external API
- is non-deterministic (like
DateTime.NoworSystem.Random) - is still under active development and might change
...is volatile. And volatile dependencies that are hardcoded into your classes are the real source of the problems DI was designed to solve.
The boundary where you decide to program against an abstraction instead of a concrete class is called a Seam. Think of it like a seam in clothing — a place where you can pull the pieces apart without tearing the fabric. Every seam is a point where you can swap implementations, intercept behavior, or isolate code for testing.
The Trouble With new
Consider a straightforward ProductService in a typical three-layer app:
public class ProductService
{
private readonly SqlProductRepository _repository;
public ProductService()
{
_repository = new SqlProductRepository();
}
public IEnumerable<Product> GetFeaturedProducts()
{
return _repository.GetFeatured();
}
}
This looks harmless. It's a single line — just new SqlProductRepository(). But that one line creates a permanent physical bond between ProductService and SqlProductRepository. The domain layer now depends on the data access layer in a way that can't be intercepted, swapped, or tested in isolation.
The damage compounds when you trace the dependency chain. The controller creates ProductService with new, so the controller is also coupled to SQL Server. Three layers of architecture, but they form a single rigid block. Change the database technology, and you're rewriting across all layers.
The book describes this as layers that are completely baked together — "Lasagna Code" that collapses into a Big Ball of Mud. You might have three folders labeled UI, Domain, and Data Access, but dependencies that cross those boundaries with new mean that your layering is a drawing of intent, not a real architectural boundary.
The Dependency Inversion Principle — and Why It Isn't DI
This is where most introductions to DI mention the Dependency Inversion Principle (DIP), often using the two terms interchangeably. They are not the same thing.
DIP is a design principle about which direction dependencies should flow. Its rule: high-level modules should not depend on low-level modules; both should depend on abstractions. Moreover, abstractions should not depend on details; details should depend on abstractions.
DI is a set of techniques for supplying those abstractions to the classes that need them. DIP says what direction the arrows should point. DI is how you make it happen in practice.
In the typical layered app, the domain depends on data access. DIP says that's backwards. The data access layer should depend on the domain, not the other way around. The domain layer should define the contract it needs (through interfaces), and the data access layer should fulfill that contract.
On the left, removing the Data Access layer breaks the Domain layer, which breaks the UI. On the right, the Domain layer owns the contracts — both the UI and Data Access plug into it. You can swap the database without touching business logic.
When you flip the dependency arrows this way, the domain layer becomes the stable core of the application. IProductRepository lives in the domain. SqlProductRepository lives in data access and implements that interface — acting as an Adapter that translates between the domain's contract and the infrastructure's reality. Similarly, an AspNetUserContextAdapter can implement a domain-defined IUserContext while internally talking to ASP.NET's HttpContext. The domain never knows about either framework.
What "Abstraction" Actually Means
Here is a subtle point the book emphasizes and that is easy to get wrong: an abstraction is not the same as an interface.
An interface is a language mechanism. An abstraction is a concept — it represents what the consumer actually needs from its dependency, expressed in the domain's own terms.
Compare these two interfaces:
// Leaky Abstraction — exposes infrastructure details
public interface IRequestContext
{
HttpContext Context { get; }
}
// True abstraction — expresses an application need
public interface IUserContext
{
bool IsInRole(Role role);
}
The first one wraps HttpContext but still forces the consumer to know about HTTP infrastructure. If you want to use ProductService in a desktop application, this abstraction brings ASP.NET with it. The book calls this a Leaky Abstraction — the implementation detail has leaked through the interface. The second interface expresses exactly what the domain needs — "can this user do this thing?" — and nothing more.
A well-designed abstraction exposes what the consumer requires and nothing it doesn't. An interface with ten methods that only two callers use is not an abstraction; it's a grab-bag. This is closely related to the Interface Segregation Principle: no client should be forced to depend on methods it doesn't use. Focused, narrow interfaces are easier to implement, easier to test against, and far less likely to leak implementation details.
Constructor Injection: Making Dependencies Visible
Once you've decided to program against abstractions, you need a way to supply concrete implementations at runtime. Constructor Injection is the primary tool for this.
public sealed class ProductService
{
private readonly IProductRepository _repository;
private readonly IUserContext _userContext;
public ProductService(
IProductRepository repository,
IUserContext userContext)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(userContext);
_repository = repository;
_userContext = userContext;
}
}
This version of ProductService is honest about what it needs. Its constructor is a contract: "I require a repository and a user context to function." You cannot construct this class without providing both. If you try, you fail immediately — at the call site, visibly, with a compiler error or a fast null-check failure — not silently, deep inside a method call, at runtime.
A few rules worth making explicit:
One public constructor. Multiple constructors introduce ambiguity about which one the caller (or DI container) should use. Stick to one.
Validate incoming arguments. Null guard clauses at the top of the constructor ensure the object is always in a valid state after construction.
Keep constructors simple. A constructor should validate its arguments and store them in readonly fields. No database calls, no file I/O, no business logic. After construction, the object should be ready to use.
Store in readonly fields. This prevents the dependency reference from being replaced after the object is built.
The simplicity rule is worth stressing. If a class can't be instantiated without doing expensive work, it becomes difficult to compose, test, and reason about. The Composition Root (discussed next) creates objects; objects should just be ready to receive their dependencies and get to work.
One more point: DI must be pervasive. You can't apply Constructor Injection in one corner of your codebase and use new everywhere else. If any class in the chain creates its own volatile dependencies, the entire chain above it is coupled to those concrete types. Loose coupling is an architectural decision, not a local fix.
Other Injection Styles
Constructor Injection covers the vast majority of cases, but two other styles appear in specific situations:
Method Injection is for dependencies that vary per operation rather than per object. If you need the current user's role only during a single method call, passing it as a method parameter is cleaner than storing it in the object:
public decimal GetPrice(Product product, IUserContext user)
{
if (user.IsInRole(Role.PreferredCustomer))
return product.Price * 0.95m;
return product.Price;
}
Property Injection allows an optional dependency to be replaced after construction. The object provides a default (often a Null Object), and callers can override it. This is the right tool only when a dependency is genuinely optional and a sensible default exists. It introduces Temporal Coupling — the object can exist before all dependencies are set — which is why it is reserved for edge cases.
The Null Object Pattern
One place where optional behavior arises cleanly is logging. Rather than making a logger nullable and scattering _logger?.Log(...) checks throughout the code, define a NullLogger that implements ILogger and does nothing. The consumer always gets an ILogger, uses it unconditionally, and remains unaware of whether it's doing real work:
public sealed class NullLogger : ILogger
{
public void Log(string message) { /* intentionally empty */ }
}
This pattern works whenever "do nothing" is a meaningful implementation of a contract.
The Decorator Pattern and Interception
Another DI-related pattern worth noting is the Decorator. A Decorator wraps an existing implementation and adds behavior — logging, caching, authorization — without changing the original class:
public class CachingProductRepository : IProductRepository
{
private readonly IProductRepository _inner;
private readonly ICache _cache;
public CachingProductRepository(IProductRepository inner, ICache cache)
{
_inner = inner;
_cache = cache;
}
public IEnumerable<Product> GetFeatured()
{
return _cache.GetOrAdd("featured",
() => _inner.GetFeatured());
}
}
The consumer still depends on IProductRepository and has no idea caching is happening. The Composition Root decides whether to wrap the real repository in a caching decorator. This ability to intercept and extend behavior without modifying existing classes is one of the three core responsibilities of DI (alongside object composition and lifetime management).
The Composition Root: Where Everything Comes Together
Constructor Injection moves the responsibility of creating dependencies out of consumers. But creation has to happen somewhere. That somewhere is the Composition Root.
The Composition Root is a single, centralized location as close to the application's entry point as possible — Program.cs in an ASP.NET Core app, Main in a console app. It is the only place in the entire codebase where you wire concrete types together.
// Inside Program.cs — the Composition Root
var connectionString = builder.Configuration.GetConnectionString("Default");
var context = new CommerceContext(connectionString);
var repository = new SqlProductRepository(context);
var userContext = new AspNetUserContextAdapter(httpContextAccessor);
var service = new ProductService(repository, userContext);
var controller = new HomeController(service);
This chain of construction calls creates the object graph — the full network of connected objects required to handle a request.
The Composition Root is the only part of the application that needs to know:
- which concrete implementations are being used
- how objects are connected
- which lifetime each object has
- when new graphs need to be created (e.g., per request)
Everything else — every controller, service, repository, and domain object — knows only about abstractions. This means the Composition Root can swap implementations without touching any application logic. Want to change the database technology? Change one concrete type in one file. Want to add a caching layer? Wrap the repository in a Decorator at the Composition Root. No application code changes.
The Apparent "Dependency Explosion"
A common concern when first looking at the Composition Root is that it seems to reference everything — UI, domain, data access. Doesn't that mean it has too many dependencies?
No. In a tightly coupled system, those dependencies already exist — they're just hidden through transitive coupling (UI depends on Domain, Domain depends on Data Access, so UI transitively depends on Data Access). The Composition Root makes those relationships explicit and concentrates them in one place, which typically reduces total coupling between application modules while increasing visibility.
Pure DI vs. DI Containers
When you build the object graph using ordinary language code — as shown above — it's called Pure DI. It's explicit, straightforward, and requires no framework.
A DI Container (also called an IoC container) automates object graph construction through configuration or convention. You register types with the container, and it figures out how to wire them together based on constructor signatures.
// With a DI Container (e.g., ASP.NET Core's built-in container)
services.AddScoped<IProductRepository, SqlProductRepository>();
services.AddScoped<IUserContext, AspNetUserContextAdapter>();
services.AddScoped<ProductService>();
Both approaches are valid. What's critical is understanding that DI and DI containers are not the same thing. DI is the design approach. A container is an optional tool that helps automate part of it. You can fully practice DI without ever using a container.
Dependency Lifetimes
One of the most consequential decisions in the Composition Root is how long each dependency lives. DI containers formalize this with named lifetimes; understanding them is essential for correctness, even if you use Pure DI.
Transient
A transient dependency is created fresh every time it's requested. Each injection point gets its own instance. This is the safest lifetime — there are no shared-state concerns — but also the most expensive, since objects are constantly created and discarded.
Transient is a good fit for lightweight, stateless services where instances are cheap to create and holding on to them provides no benefit.
Scoped
A scoped dependency lives for the duration of a defined scope — typically one HTTP request in a web application, or one unit of work in a background service. Within a single scope, every consumer that asks for the dependency gets the same instance. A new scope (a new request) gets a fresh instance.
This lifetime is ideal for things that should be consistent within an operation but should not be shared across operations. An Entity Framework DbContext is the canonical example: you want the same DbContext throughout one request (so you can track changes and commit them together), but you definitely don't want it shared between requests.
Singleton
A singleton is created once and shared for the entire lifetime of the application. Every consumer that ever needs this dependency gets the same instance.
Singletons are appropriate for genuinely shared, long-lived resources: connection pool managers, in-memory caches, thread-safe configuration readers. But they come with important responsibilities:
- The implementation must be thread-safe, since multiple threads may use it concurrently.
- It should avoid shared mutable state unless that state is properly synchronized.
- It should not depend on shorter-lived objects (see below).
Captive Dependencies: When Lifetimes Collide
The most insidious lifetime bug is the captive dependency. It happens when a longer-lived object holds a reference to a shorter-lived one:
Singleton
└── depends on ──► Scoped dependency
The singleton is created once and cached. When it captured its scoped dependency at construction time, it captured that specific instance permanently. Now every request uses the same scoped instance, regardless of scope boundaries.
Imagine UserAuditLogger is a singleton that holds an IUserContext (scoped per HTTP request). After the first request, UserAuditLogger still holds the first user's context. Every subsequent request "sees" that first user's identity through the logger — a subtle, hard-to-reproduce bug that only appears under load.
The rule is simple: a dependency's lifetime must be at least as long as any consumer that holds it. Most DI containers will detect this mismatch and throw an exception during startup. With Pure DI you have to reason about it yourself, which is one reason explicit composition can be valuable for catching these issues early.
Anti-Patterns: What DI Is Trying to Prevent
Understanding DI is easier when you understand what goes wrong without it. The book identifies four DI anti-patterns. Two of them — Control Freak and Service Locator — are by far the most common and damaging, so they receive the most attention here.
Control Freak
A class exhibits the Control Freak pattern when it creates its own volatile dependencies, even when those dependencies are assigned to an abstraction variable:
public class ProductService
{
private readonly IProductRepository _repository;
public ProductService()
{
// Still Control Freak — the concrete type is hardcoded
_repository = new SqlProductRepository();
}
}
The key question is not "does this code reference an interface?" but "who chooses the implementation?" If the class chooses, it is a Control Freak. If the Composition Root chooses, it is proper DI.
Factories don't automatically fix this. Moving new SqlProductRepository() into a RepositoryFactory shifts the coupling into the factory but doesn't remove it. If ProductService depends on that concrete factory, it still transitively depends on the concrete repository — and on everything the repository depends on.
A particularly sneaky variant is the Foreign Default (sometimes called Bastard Injection) — a constructor-chaining pattern where one constructor provides a default volatile dependency:
public ProductService()
: this(new SqlProductRepository()) // Foreign Default
{
}
public ProductService(IProductRepository repository)
{
_repository = repository;
}
This looks convenient — tests can use the injectable constructor while production uses the default. But the domain module now has a compile-time dependency on SqlProductRepository, which drags the entire data access layer along wherever the domain is reused.
The fix is almost always Constructor Injection with a single constructor. The dependency gets declared explicitly, the Composition Root decides which implementation to provide, and the class stops caring about origins.
Service Locator
Service Locator is the more deceptive of the two anti-patterns. Instead of creating dependencies directly, the class queries a shared resolver:
public class ProductService
{
private readonly IProductRepository _repository;
public ProductService()
{
_repository = Locator.GetService<IProductRepository>();
}
}
This looks like DI because the class programs against an interface and doesn't hardcode SqlProductRepository. But the class is still responsible for finding its own dependency. It just delegates that search to the Locator.
The damage surfaces in four ways:
Hidden dependencies. The constructor signature shows ProductService taking no arguments. A developer reading this sees a class with no apparent dependencies. The actual requirement for IProductRepository is buried inside the method body. The class lies about what it needs.
Runtime errors instead of compile-time errors. With Constructor Injection, trying to create ProductService without a repository fails immediately — the compiler won't allow it. With Service Locator, forgetting to register the repository compiles fine and fails at runtime, possibly in production.
Temporal coupling. The Locator must be configured before the consumer calls it. The required order of operations is invisible and easy to violate.
Test fragility. Testing a Service Locator consumer requires setting up global Locator state before each test and tearing it down afterward. Tests can accidentally influence each other through that shared global state.
With Constructor Injection, all of this goes away:
// In tests: clean, explicit, no shared state
var sut = new ProductService(new StubProductRepository());
The important clarification: using a DI container's Resolve<T>() method is not automatically Service Locator. What makes it Service Locator is using it from within application business code to pull dependencies on demand. Using it in the Composition Root to build the initial object graph is correct usage.
Ambient Context
Ambient Context is a third anti-pattern where a dependency is made globally available through a static accessor — TimeProvider.System, a static logging instance, or a static SecurityContext.Current. Like Service Locator, it hides dependencies behind an invisible access point, makes testing harder, and introduces shared global state. The fix is to inject the dependency explicitly — for example, injecting an ITimeProvider rather than calling a static DateTime.Now.
Constrained Construction
Constrained Construction occurs when a framework forces all classes to have a specific constructor signature — usually parameterless. This prevents Constructor Injection from working normally and pushes developers toward one of the other anti-patterns. The solution is integration code (a custom activator or factory adapter) that bridges the framework's requirements with proper DI.
Code Smells: Signals Worth Investigating
Anti-patterns are known-bad solutions. Code smells are weaker signals — hints that something might be off, not proof of a problem. They are worth investigating, not automatically fixing.
Constructor Over-Injection
When a class has many constructor parameters, that's a smell:
public OrderService(
IOrderRepository repository,
IMessageService messages,
IBillingSystem billing,
ILocationService locations,
IInventoryManagement inventory)
The important insight is that Constructor Injection didn't cause this problem; it revealed it. A class with five infrastructure dependencies was always doing too much. Before Constructor Injection, those dependencies were hidden inside the class body. Now they're explicit — and that visibility is informative.
The smell usually indicates a Single Responsibility Principle (SRP) violation. The fix is not to hide the parameters by moving them to properties (that creates Temporal Coupling and hides the problem). The fix is to ask: what natural clusters of behavior belong together?
Facade Services are one answer. If ILocationService and IInventoryManagement always collaborate to fulfill orders, introduce:
public interface IOrderFulfillment
{
void Fulfill(Order order);
}
This abstraction captures a meaningful business concept, not an arbitrary grouping of parameters. The class's constructor shrinks, and the new interface represents something real.
Domain Events are another direction. Instead of OrderService directly notifying five other systems when an order is approved, define an OrderApproved event type and let independent handlers react to it:
public interface IEventHandler<TEvent>
{
void Handle(TEvent e);
}
public class OrderFulfillment : IEventHandler<OrderApproved>
{
public void Handle(OrderApproved e) { /* fulfill order */ }
}
A Composite event handler wraps all the implementations:
public void Handle(OrderApproved e)
{
foreach (var handler in _handlers)
handler.Handle(e);
}
Now OrderService depends on one abstraction. Adding new reactions to an order approval doesn't change OrderService at all — just add a new handler and register it in the Composition Root.
In both cases, the complexity doesn't disappear — it moves to the Composition Root, where it belongs.
Abusing Abstract Factories
Abstract Factories have legitimate uses, but they're often introduced where they aren't needed.
Factory as lifetime manager. If you see a parameterless factory like:
public interface IProductRepositoryFactory
{
IProductRepository Create();
}
...and the consumer is calling Create() and then Dispose()-ing the result, the consumer is managing a dependency's lifetime. That's not its job. Lifetime management belongs in the Composition Root. The right design is usually to inject IProductRepository directly and let the Composition Root manage when instances are created and cleaned up.
This is reinforced by a subtler point: if an interface extends IDisposable, the consumer is being asked to own the dependency's lifetime. But disposal is often an implementation detail. SqlRepository might need disposal; InMemoryRepository for tests doesn't. The abstraction shouldn't force a disposal pattern onto all implementations just because one needs it.
Factory returning another service. A factory whose Create() method returns another service abstraction is worth scrutinizing:
// Consumer now knows about two abstractions
public interface IRouteAlgorithmFactory
{
IRouteAlgorithm CreateAlgorithm(RouteType routeType);
}
// Better — one abstraction hiding the selection logic
public interface IRouteCalculator
{
RouteResult Calculate(RouteSpecification spec, RouteType routeType);
}
The guideline: service abstractions should not expose other service abstractions through their parameters or return values. It's a code smell, not an absolute rule, but when you see it, it's worth questioning whether a higher-level abstraction could hide the complexity.
The Proxy Pattern for Lazy Creation
Sometimes lazy creation is genuinely needed — perhaps the real implementation is expensive to construct and is often not needed. Rather than exposing a factory to consumers, use a Proxy:
public class LazyProductRepository : IProductRepository
{
private IProductRepository _inner;
public IEnumerable<Product> GetFeatured()
{
_inner ??= CreateRealRepository();
return _inner.GetFeatured();
}
}
From the consumer's perspective, it received IProductRepository and used it normally. The lazy behavior is an implementation detail fully encapsulated in the proxy. The consumer doesn't need a factory; it doesn't manage any lifetime.
Cyclic Dependencies
If two classes depend on each other — directly or through a chain — you have a cyclic dependency:
SqlUserRepository → IAuditTrailAppender → IUserContext → IUserRepository → SqlUserRepository
This is impossible to construct. There's no ordering of new calls that satisfies the graph.
The immediate instinct is to find a DI trick that breaks the cycle. Resist this. Cyclic dependencies are almost always caused by SRP violations — a responsibility that belongs in a separate class has been merged into an existing one. Split the responsibilities and the cycle often dissolves naturally.
The book's preferred order of resolution:
- Split classes — redesign responsibilities so no cycle exists. This is almost always the correct answer.
- .NET events — if one side only needs to notify the other, an event removes the direct dependency.
- Property Injection — a last resort when redesign isn't feasible. It breaks the cycle but introduces Temporal Coupling (the object is partially initialized after construction) and treats the symptom rather than the underlying design problem.
Connecting the Pieces: A Mental Model
After six chapters of the book, a coherent picture emerges.
DI is about a fundamental division of responsibility. Application classes describe what they need. The Composition Root decides what they get.
Everything else follows from that:
- Classes declare volatile dependencies in their constructors — Constructor Injection.
- The Composition Root is the only place with knowledge of concrete types — it owns the object graph.
- The Dependency Inversion Principle says high-level modules define the contracts; lower-level modules fulfill them.
- Abstractions express what consumers need, not what implementations provide — which is why a narrow
IUserContextbeats a wrappedHttpContext. - Lifetimes belong to the Composition Root — not to consumers managing factories or classes calling
Dispose(). - Code smells like a Constructor Over-Injection point to design problems that Constructor Injection is revealing, not creating.
The Composition Root sits at the top — the only part that knows about everything. Application classes depend only on abstractions. Concrete implementations plug into those abstractions from the outside. Lifetimes are managed as a concern of composition, not consumption.
When this architecture holds throughout a codebase, the benefits extend well beyond testability: swap infrastructure without touching business logic, add cross-cutting concerns with Decorators, manage resource lifetimes without leaking that detail into domain code, and let teams work on separate modules without stepping on each other.
A DI container can help automate some of this wiring. But it is a tool, not the architecture. The architecture comes from understanding what problems DI was designed to solve.
Closing Thought
The beginner's version of DI — "inject interfaces through constructors" — is correct, but it's the mechanics without the meaning. The meaning is this:
Your classes describe what they need. The Composition Root decides what they get.
When that division of responsibility holds throughout an entire codebase — consistently, at every layer, with abstractions that genuinely represent consumer needs rather than implementation details — you have a loosely coupled system. Not because you used a framework. Not because you have interfaces everywhere. Because every class is honestly ignorant of the concrete world it operates in, and one single location takes responsibility for assembling that world correctly.
That is what Dependency Injection is actually about.





Top comments (0)