<arch.design/>
Principles/Dependency Injection
{ }CodeArchitecturebeginner2004iocconstructor-injectioninversion-of-controltestability

Dependency Injection

Supply a component's dependencies from the outside rather than letting it construct them — so implementations can be swapped without changing the component.

5/5
{ }
Operates at: Code level

Inside a codebase — classes, modules, files

How it works

Dependency Injection (DI) is the practice of passing a component's collaborators to it (via constructor, method, or property) rather than letting it instantiate them with new or static calls. Martin Fowler named the pattern in 2004, building on the earlier Inversion of Control (IoC) principle.

In constructor injection — the preferred form — all required dependencies are declared in the constructor signature. A caller (or a DI container) provides them. The component only knows about the interface, not the concrete class.

DI containers (Spring, Angular's injector, NestJS, ASP.NET DI) automate the wiring: you register bindings (interface → class) once, and the container resolves the full dependency graph on demand. Go and Rust typically use manual constructor injection — simpler and just as effective.

Project structure

recommended layout
project structure
src/
├── interfaces/
│ ├── email-service.ts # Contract
│ └── payment-service.ts
├── services/
│ ├── smtp-email.service.ts # Production implementation
│ ├── mock-email.service.ts # Test / dev implementation
│ └── order-service.ts # Depends on interfaces
├── container.ts # Wire all dependencies once
└── app.ts # Entry point — use container

Implementation

TypeScript · Go · Rust
// interfaces/email-service.ts
export interface EmailService {
  send(to: string, subject: string, body: string): Promise<void>;
}

// services/smtp-email.service.ts — production
export class SmtpEmailService implements EmailService {
  constructor(private config: SmtpConfig) {}
  async send(to: string, subject: string, body: string) {
    await nodemailer.createTransport(this.config)
      .sendMail({ to, subject, html: body });
  }
}

// services/mock-email.service.ts — testing
export class MockEmailService implements EmailService {
  readonly sent: Array<{ to: string; subject: string }> = [];
  async send(to: string, subject: string) {
    this.sent.push({ to, subject });   // no network, inspect in tests
  }
}

// services/order-service.ts — receives dependencies, doesn't create them
export class OrderService {
  constructor(
    private repo: OrderRepository,
    private email: EmailService,   // ← interface, not SmtpEmailService
  ) {}

  async placeOrder(order: NewOrder): Promise<string> {
    const saved = await this.repo.save(order);
    await this.email.send(
      order.customerEmail, "Order confirmed", `Order ${saved.id} placed.`
    );
    return saved.id;
  }
}

// container.ts — single place that knows about concrete classes
const email   = new SmtpEmailService(config.smtp);
const repo    = new PgOrderRepository(db);
export const orderService = new OrderService(repo, email);

Why it matters

Without DI, components are tightly coupled to their collaborators. Changing an email provider, database, or third-party API requires modifying the component. With DI, you swap the implementation without touching the component — and unit tests inject mocks for complete isolation.

When to use

  • Any class with external collaborators (databases, email, APIs, services)
  • When unit testing requires replacing real collaborators with test doubles
  • Medium-to-large codebases where wiring is too complex to manage manually
  • Frameworks like Spring, NestJS, Angular already expect it

When NOT to use

  • Simple scripts or small utilities with no meaningful collaborators
  • When DI container overhead is a concern (some embedded or real-time systems)

Trade-offs

+

Collaborators are swappable — easy testing with mocks

DI containers add indirection that can make code harder to trace

+

Explicit dependencies improve readability and design

Constructor bloat when a class has many dependencies (signals it needs splitting)

+

Loose coupling enables parallel development and modularity

Misconfigured bindings fail at runtime (compile-time DI frameworks mitigate this)

In production

Spring / Spring Boot

Entire Java/Kotlin enterprise ecosystem built on DI — @Autowired, @Bean, ApplicationContext

Angular

Hierarchical DI injector is core to how Angular components and services are composed

NestJS

TypeScript DI container modelled after Angular; all providers are injected via decorators

Industry adoption

5/5Ubiquitous — used at virtually every scale-focused company.

Related principles