Patterns for Humans software concepts, explained simply
← All concepts
pattern

Dependency Injection

Stop building your own tools. Let someone hand them to you.

The simple version

A class shouldn’t go out and build the things it depends on. It should just say what it needs, and let someone else hand it over.

The analogy

Imagine a chef who, every time a recipe calls for olive oil, walks to a specific farm, harvests olives, and presses them himself — right there in the kitchen, mid-recipe.

That’s insane, obviously. A chef says “I need olive oil” and someone else — a supplier — makes sure a bottle is on the shelf. The chef doesn’t care which farm it came from, how it was pressed, or who delivered it. He just opens the bottle and cooks.

Dependency Injection is the kitchen having a supplier instead of a farm.

The bridge

In code, a class that “goes and builds its own olive oil” looks like this:

class OrderService {
  private emailSender = new SmtpEmailSender(); // built it myself, right here

  placeOrder(order: Order) {
    // ...
    this.emailSender.send(order.customerEmail, "Order confirmed");
  }
}

OrderService has now welded itself to SmtpEmailSender. Want to send via a different provider in production, or fake it out in a test? Too bad — you’d have to edit OrderService itself to change it.

Dependency Injection just means: the class receives its dependency from the outside, instead of constructing it internally.

class OrderService {
  constructor(private emailSender: EmailSender) {} // handed to me

  placeOrder(order: Order) {
    // ...
    this.emailSender.send(order.customerEmail, "Order confirmed");
  }
}

Now OrderService only knows about the EmailSender interface — the shape of “something that can send emails.” Whoever wires the app together decides which concrete implementation to hand it: SMTP in production, a fake one in tests, a queue-based one next year. OrderService never has to change.

This is why DI and testability are basically the same conversation: a class you can hand fake dependencies to is a class you can test in isolation, with no real database, no real network call, no real email sent.

In code

// The interface — what OrderService actually depends on
interface EmailSender {
  send(to: string, message: string): void;
}

// Production implementation
class SmtpEmailSender implements EmailSender {
  send(to: string, message: string) { /* talks to a real SMTP server */ }
}

// Test double — no network, just a spy
class FakeEmailSender implements EmailSender {
  sent: { to: string; message: string }[] = [];
  send(to: string, message: string) { this.sent.push({ to, message }); }
}

// Wiring happens once, at the edge of the app
const orderService = new OrderService(new SmtpEmailSender());

// In a test, swap the ingredient without touching OrderService at all
const fake = new FakeEmailSender();
const testService = new OrderService(fake);

Most DI frameworks (a container, @Injectable() decorators, etc.) just automate the wiring step. The core idea — pass dependencies in, don’t construct them inside — works with zero framework at all, as shown above. That’s usually called “poor man’s DI,” and it’s a perfectly legitimate way to do this in a small codebase.

When you don’t need it

If a dependency is a stable, stateless utility with no side effects — say, a pure math function or a date formatter — injecting it is often ceremony for no benefit. DI earns its keep when a dependency talks to the outside world (a database, an API, the filesystem, the clock) or when you genuinely need to swap implementations. Don’t inject everything reflexively; inject what actually varies.