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.
Unlearning Four Myths First
Before diving in, it's worth clearing the air on four misconceptions that cling to DI like barnacles:
DI is only for late binding. DI does let you swap components — say, Oracle for SQL Server — without recompiling. But that's a side-effect, not the point. The real benefit is architectural: maintainable, extensible code where the pieces don't bleed into each other.
DI is only for unit testing. Test doubles (mocks, stubs, fakes) become easy when dependencies are injected, sure. But DI's benefits — parallel development, swappable infrastructure, cleaner cross-cutting concerns — reach far beyond the test suite.
DI is an Abstract Factory on steroids. This misconception leads directly to the Service Locator anti-pattern, where a class queries a generic factory for what it needs. DI is the opposite: classes passively declare what they need; something outside them decides what they get.
DI requires a DI Container. A container is an optional tool that automates wiring. You can practice DI perfectly well with plain language code — no framework required. This is called Pure DI, and it's a first-class approach.
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. If ProductService queries a database to retrieve products, the database connection is a dependency. If it applies a discount based on the current user's role, the user context is a dependency. If 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 a concrete type is instantiated directly inside the class body — with new, a static factory call, or similar (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 choose to program to an abstraction rather than 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. Swapping the database affects only the data access implementation — business logic that lives in the domain layer doesn't need to change. (Business logic embedded in stored procedures or aggregation pipelines is a separate concern; if that logic moves, it moves with the database.)
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 depend on abstractions rather than concrete types, 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 contextual values the caller already has — a per-call identifier, a clock reading, a currency — not for services the method would have to go and resolve. If you need contextual data for a single method call, passing it as a parameter is cleaner than storing it in the object:
public decimal GetPrice(Product product, Currency targetCurrency)
{
return _converter.Exchange(product.Price, targetCurrency);
}
Passing a full service abstraction as a method parameter (e.g., IUserContext) is a different matter — it's the same code smell that the Abstract Factory section discusses: the caller now has to know about and provide two abstractions instead of one.
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 is appropriate whenever "do nothing" is a meaningful and safe implementation. A NullLogger that silently drops log entries is harmless. A NullAuditWriter or NullPaymentGateway that silently drops records is not — misconfiguration becomes invisible data loss. Use the Null Object only where silence is a valid behavior, not merely a convenient one.
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 async Task<IEnumerable<Product>> GetFeaturedAsync(
CancellationToken ct = default)
{
return await _cache.GetOrAddAsync("featured",
() => _inner.GetFeaturedAsync(ct));
}
}
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).
Decorator order matters — and it is a correctness property, not a preference. Authorization(Caching(Repository)) caches authorization decisions; Caching(Authorization(Repository)) caches data per user. The second is wrong for shared caches. The usual safe order is: authorization outermost, then caching, then retry, then the real implementation. Also note: a cache keyed purely by entity type (as the "featured" key above is) serves the same result to every caller. If your repository applies per-user or per-tenant filtering underneath, the cache key must include those dimensions, or one user will see another's data.
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 to each other
- 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 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>();
A quick note on terminology: you may see DI containers called "IoC containers." Inversion of Control is a much broader idea — DI is just one form of it. The "IoC container" label is a historical misnomer that stuck. Prefer "DI container" to be precise.
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.
One Composition Root Per Entry Point
One nuance worth naming: you need one Composition Root per deployable entry point, not one per system. A solution with a web API and a background worker has two entry points — each gets its own Composition Root. A shared composition assembly that every deployable references is a common misread of "single," and it makes the wiring of each deployable harder to understand and test in isolation.
When One Interface Has Multiple Implementations
A common question once DI clicks: what if you have more than one implementation of the same interface and different consumers need different ones?
The answer depends on why they differ:
-
Runtime selection based on data (e.g., choose a routing algorithm by user preference): use a higher-level abstraction that hides the selection logic internally (see the
IRouteCalculatorexample in the code smells section). The consumer declares what it needs, not which variant. - Different consumers genuinely need different implementations (e.g., a read-only repository for queries and a full repository for writes): give each a distinct interface. If they truly need different contracts, they should say so explicitly.
- Some consumers should be wrapped, others not (e.g., a caching decorator for one consumer): wire them differently in the Composition Root. With a DI container, named registrations or keyed services let you say exactly which implementation goes where.
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.
The conventional wisdom — "transient is safest, just more allocation" — is misleading on both counts. Allocation itself is rarely the cost; it is per-instance setup like acquiring connections or building handler chains. More importantly, transient is not automatically the safe choice: if a transient implements IDisposable, the container must track and dispose it. The ASP.NET Core built-in container only does this within a scope — transient IDisposable objects resolved outside a scope are held for the life of the application, which is one of the most common lifetime leaks. HttpClient is the canonical example: constructing one per use exhausts available sockets, yet using a singleton pins stale DNS. Neither extreme works; it needs a deliberately managed lifetime via IHttpClientFactory.
Transient is a good fit for lightweight, stateless services that are cheap to create and don't implement IDisposable.
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, and in a multi-tenant application it can mean one tenant seeing another's data.
The rule is plain: a dependency can never live shorter than its consumer. Equivalently: a component may only consume dependencies of equal or longer lifetime.
Many DI containers can detect this mismatch, but the detection isn't always on by default where it matters. In ASP.NET Core, ValidateScopes and ValidateOnBuild are enabled by WebApplication.CreateBuilder only in the Development environment. This means a singleton capturing a scoped DbContext throws on a developer's machine and silently passes in production — where you then get an unbounded change tracker, concurrent use of a non-thread-safe type, and potential data leaks. To catch this everywhere, opt in explicitly:
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});
With Pure DI you have to reason about lifetimes yourself — which is one reason the explicit wiring can actually be an advantage: the compiler forces you to think through the graph.
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 and domain code makes decisions based on it — think SystemClock.Now called directly from a pricing rule, or a static SecurityContext.Current checked inside a domain service. Like Service Locator, it hides dependencies, makes testing harder, and introduces shared global state.
The fix is to inject the dependency explicitly. The .NET TimeProvider class is exactly this fix for the clock: inject it and register TimeProvider.System in the Composition Root. The anti-pattern is calling DateTime.UtcNow directly from domain code, not using TimeProvider.
The rule of thumb: if your code makes a decision based on a global value, inject it. Values that are merely carried through (trace context, correlation IDs) are reasonably ambient — they don't distort your domain logic.
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.
Abstractions and IDisposable
When an abstraction extends IDisposable, the consumer is implicitly asked to manage the dependency's lifetime — to know when to dispose it and to call Dispose(). But disposal is an implementation detail, not a conceptual contract. SqlRepository might need disposal; InMemoryRepository for tests doesn't. Forcing all implementations to satisfy a disposal contract just because one requires it is a Leaky Abstraction.
The cleaner position: concrete implementations should implement IDisposable as they see fit, and lifetime management — including disposal — should be the Composition Root's responsibility, not the consumer's.
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. - If any plausible implementation of an abstraction is out-of-process, the abstraction should be async and accept a
CancellationToken. A synchronousGetFeatured()forces adapter authors to block on async I/O, which is how you get thread-pool starvation. This can't be fixed locally — addingTask<T>breaks every caller up to the entry point. - Lifetimes belong to the Composition Root — not to consumers managing factories or classes calling
Dispose(). - Code smells like Constructor Over-Injection point to design problems that Constructor Injection is revealing, not creating.
- DI must not be applied to entities and value objects. They hold data and express domain rules; injecting services into them breaks ORM materialization and blurs the line between domain model and service layer.
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 practical addition: writing a test that builds the container and resolves every registration catches captive dependencies, missing wiring, and misconfigured lifetimes before they reach production. It costs almost nothing to write and pays for itself the first time it catches a scoped service leaking into a singleton.
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)