Dependency Injection — IoC Containers Explained (2026)
In this tutorial, you'll learn how dependency injection (DI) decouples object creation from object usage, how IoC containers manage dependencies automatically, and why DI is essential for testable, maintainable code in large applications.
Think of dependency injection like ordering a pizza. You don't grow the wheat, mill the flour, and bake the crust yourself — you call the pizzeria and they deliver. The pizzeria (the IoC container) handles all the preparation. You just say what you want (the interface) and receive a ready-to-use product (the implementation). If the pizzeria changes suppliers, you don't care — your pizza still arrives.
Core Concept
Dependency injection means giving an object its dependencies from the outside rather than having it create them internally. Instead of this.db = new Database() inside a class, you receive the database connection through the constructor.
There are three common injection styles:
- Constructor injection — dependencies passed when the object is created (most common)
- Setter injection — dependencies set via setter methods after creation
- Interface injection — the object implements an interface that receives the dependency
// Without DI — class creates its own dependencies
class UserService {
private db = new Database("localhost", 5432);
private logger = new FileLogger("logs/app.log");
}
// With constructor injection — dependencies are provided
class UserService {
constructor(
private db: Database,
private logger: Logger
) {}
async getUser(id: string) {
this.logger.info(`Fetching user ${id}`);
return this.db.query("SELECT * FROM users WHERE id = $1", [id]);
}
}
Expected output: The injected version accepts any Database and Logger that match the expected interfaces. Tests can pass mock implementations. Production passes real ones.
How It Works
An IoC (Inversion of Control) container manages dependency creation and lifetime. You register interfaces and their implementations once, then request them when needed.
// Container setup
const container = new Container();
container.register("Database", {
useClass: PostgresDatabase,
lifetime: "singleton" // one instance for the app
});
container.register("Logger", {
useClass: FileLogger,
lifetime: "transient" // new instance every time
});
container.register("UserService", {
useClass: UserService,
lifetime: "scoped" // one instance per request
});
// Resolving — container builds the entire object graph
const service = container.resolve("UserService");
// Container sees UserService needs Database and Logger,
// creates both, and injects them automatically.
┌────────────────────────────────────────────────┐
│ IoC Container │
│ │
│ UserService ← needs → Database (singleton) │
│ → Logger (transient) │
│ │
│ When resolve("UserService") is called: │
│ 1. Create Database (once, reuse for all) │
│ 2. Create Logger (new instance) │
│ 3. Create UserService with both │
│ 4. Return ready-to-use UserService │
└────────────────────────────────────────────────┘
Lifetime Management
Three common lifetimes control when objects are created and destroyed:
| Lifetime | Behavior | Use Case |
|---|---|---|
| Transient | New instance every time it's requested | Lightweight, stateless services |
| Scoped | Same instance within a scope (e.g., one HTTP request) | Database contexts, unit of work |
| Singleton | Same instance for the entire application | Configuration, logging, caching |
DI Anti-Patterns
- Service locator — a global registry that classes query for dependencies (hides dependencies, makes testing harder)
- Constructor over-injection — a constructor with 7+ parameters (signals the class does too much)
- DI container as a dependency — passing the container itself to classes so they resolve their own dependencies (defeats the purpose)
Real-World Examples
Testing with Mocks
In Doda Browser's rendering engine, network requests and cache lookups are injected. Unit tests provide mock implementations that return fixture data — no network calls, no shared cache state. This makes the test suite fast and deterministic.
// Production
const renderer = new PageRenderer(
new NetworkFetcher(),
new DiskCache("/tmp/cache")
);
// Test
const renderer = new PageRenderer(
new MockFetcher(fixtureData), // returns test data
new NullCache() // does nothing
);
Framework DI
Modern frameworks like Angular, NestJS, and Spring Boot are built around DI. In NestJS, you decorate a service with @Injectable() and the framework's DI container resolves all dependencies automatically — no manual wiring needed.
Pros & Cons
| Pros | Cons |
|---|---|
| Classes are easier to test with mocks | Can make code harder to follow (who wires what?) |
| Dependencies are explicit and visible | Container configuration can grow complex |
| Object lifetime is managed centrally | Runtime errors if bindings are misconfigured |
| Promotes interface-based design | Overuse leads to fragmented design |
When to Use
DI is valuable in any application larger than a script. Use it when:
- You write unit tests — DI is the primary enabler for mocking dependencies
- Your application has multiple environments (dev/staging/production use different implementations)
- You want centralized control over object creation and lifetime
- You're using a framework that supports DI — fight the framework, lose
Skip DI for tiny scripts, performance-critical hot paths, or when the indirection reduces clarity without benefit.
FAQ
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro