Dependency injection is the principle behind every testable service: instead of a class building its own collaborators, those collaborators are handed in from outside. It sounds almost trivially simple, yet the absence of it is the number-one reason codebases become un-testable. By 2026, every major framework — Spring, NestJS, ASP.NET, Angular — is built around DI. You need to understand it to use those frameworks and to design good code without them.
What changed in 2026
- TypeScript decorators are now stable (TC39 Stage 3), so NestJS-style
@Injectable() annotation-based DI works without the experimental flag in modern toolchains.
- Python 3.12+
typing.Protocol made interface-based injection idiomatic in Python without ABCs.
- Spring Boot 4 / Quarkus 3 simplified DI wiring further, reducing boilerplate for constructor injection.
- Lightweight DI for edge runtimes gained traction — full containers are often skipped in favour of manual factories at Cloudflare Workers / Deno Deploy scale.
The core concept
Without DI:
class OrderService {
private db = new PostgresDatabase(); // hard dependency — can't swap for a test
async placeOrder(order: Order) {
return this.db.insert("orders", order);
}
}
With DI (constructor injection):
class OrderService {
constructor(private db: Database) {} // receive it; don't build it
async placeOrder(order: Order) {
return this.db.insert("orders", order);
}
}
// Production
const svc = new OrderService(new PostgresDatabase());
// Test
const svc = new OrderService(new FakeDatabase());
The service no longer cares where the database comes from. A test can pass a FakeDatabase without touching real infrastructure.
Three forms of injection
| Form |
How |
Best for |
| Constructor injection |
Dependency passed via constructor |
Default; makes dependencies explicit |
| Property injection |
Dependency set on a property after construction |
Optional dependencies, plugins |
| Method injection |
Dependency passed to a specific method |
One-off overrides, rarely needed |
Constructor injection is almost always the right default. It makes every dependency visible and required.
DI containers vs manual wiring
A DI container (IoC container) automates wiring: you register types, and the framework resolves the graph and constructs the object tree.
// NestJS — the container handles construction
@Injectable()
class OrderService {
constructor(private db: DatabaseService) {}
}
@Module({ providers: [OrderService, DatabaseService] })
class AppModule {}
Manual wiring — you call constructors yourself at the composition root:
// index.ts — composition root
const db = new PostgresDatabase(config.dbUrl);
const orderService = new OrderService(db);
const orderController = new OrderController(orderService);
| Approach |
Pros |
Cons |
| DI container |
Less boilerplate, lifecycle management, scopes |
Magic, harder to trace, framework lock-in |
| Manual wiring |
Explicit, debuggable, zero overhead |
Verbose for large graphs |
For services with <20 classes, manual wiring is often clearer.
Service locator anti-pattern
A service locator looks like DI but is the opposite:
// Anti-pattern — hidden dependency
class OrderService {
async placeOrder(order: Order) {
const db = ServiceLocator.get<Database>("db"); // invisible dependency
return db.insert("orders", order);
}
}
The dependency is not in the constructor; callers cannot see it, tests cannot replace it without mutating global state, and reasoning about the class requires reading its body. Avoid this.
How to pick
- New service with tests from day one — constructor inject everything; start without a container.
- Growing service (10+ classes) with lifecycle concerns (singletons, scopes, async init) — add a container.
- Framework already chosen (NestJS, Spring, ASP.NET) — use the built-in DI; don't fight it.
- Edge runtime or serverless — prefer manual wiring or lightweight factories; heavy containers add cold-start cost.
Common mistakes
Injecting concrete classes, not interfaces. If you inject PostgresDatabase instead of Database, you can't swap it in tests. Program to interfaces/protocols.
Circular dependencies. Two services that depend on each other cause a container deadlock. Break the cycle with an interface, an event, or a third service.
Over-injection. Not every object needs to be injectable. Value objects, DTOs, and pure functions are constructed normally.
Giant "god" modules. Grouping everything into one module destroys the modularity benefit. Split by feature domain.
What to skip
- Property injection for required dependencies — it lets the object be created in an invalid state.
- Dynamic service locators — they undo the transparency that DI provides.
- DI frameworks in tiny scripts — a 50-line CLI does not need a container.
FAQ
Is DI the same as inversion of control?
DI is one implementation of inversion of control (IoC). IoC is the broader principle; DI is the specific pattern of passing dependencies in.
How do I test code that uses DI?
Create the class under test directly with mocks or fakes passed into the constructor. No container needed in unit tests.
Can I use DI in Python?
Yes. Libraries like python-dependency-injector provide containers. More commonly, teams use plain constructor injection with typing.Protocol for interface definitions.
Does DI hurt performance?
Containers have a tiny startup cost for building the graph. At runtime the overhead is zero — objects are constructed once and reused.
Where to go next
See Unit testing explained in 2026, Mocking explained in 2026, and Design patterns explained in 2026.