Skip to content

Dependency Injection — IoC Containers Explained (2026)

DodaTech Updated 2026-06-20 5 min read

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

What is the difference between DI and IoC?

Inversion of Control (IoC) is the broader principle of handing control to a framework. Dependency Injection is one specific technique to achieve IoC — instead of a class controlling its dependencies, they are injected from outside.

Do I need a DI container or can I do it manually?

You can do DI manually — it's called "poor man's DI" and works well for small apps. A container becomes valuable when your dependency graph grows. Manually wiring 50+ classes with different lifetimes is error-prone and tedious.

How does DI relate to the Dependency Inversion Principle?

DI and the Clean Architecture's Dependency Inversion Principle (DIP) are complementary. DIP says "depend on abstractions, not concretions." DI provides the mechanism to do that — you depend on interfaces and receive concrete implementations at runtime.

What happens if a circular dependency is detected?

Most DI containers detect circular dependencies and throw an error at startup. Fix by breaking the cycle — extract an interface, introduce a third class, or use an event-based approach instead of direct references.

Should all classes use DI?

No. Value objects, DTOs, primitive types, and simple utility classes don't need DI. Reserve DI for services, repositories, and other classes that have external dependencies or need testability

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro