I'll Be Backend
Design patterns · TypeScript

Dependency injection: stop creating collaborators inside

Run the same class with different collaborators without editing the class itself.

14 steps4 chapters~6 min at 1.25×#dependency injection#constructor injection#composition root#decoupling
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the TypeScript video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–5

Refactor

Steps 6–10

Payoff

Steps 11–12

Recap

Steps 13–14

The code: before and after

Before

class RateCard {
  constructor(private readonly perHour: number) {}

  price(hours: number): number {
    return hours * this.perHour;
  }
}

class ReceiptPrinter {
  constructor(private readonly branch: string) {}

  print(bikeId: number, euros: number): string {
    return `${this.branch}: bike ${bikeId}, ${euros} EUR`;
  }
}

class ReturnDesk {
  private readonly rates: RateCard;
  private readonly printer: ReceiptPrinter;

  constructor() {
    this.rates = new RateCard(3);
    this.printer = new ReceiptPrinter("City Center");
  }

  checkIn(bikeId: number, hours: number): string {
    const euros = this.rates.price(hours);
    return this.printer.print(bikeId, euros);
  }
}

function rentals(): string[] {
  const desk = new ReturnDesk();
  return [desk.checkIn(7, 3)];
}

After

class RateCard {
  constructor(private readonly perHour: number) {}

  price(hours: number): number {
    return hours * this.perHour;
  }
}

class ReceiptPrinter {
  constructor(private readonly branch: string) {}

  print(bikeId: number, euros: number): string {
    return `${this.branch}: bike ${bikeId}, ${euros} EUR`;
  }
}

class ReturnDesk {
  constructor(
    private readonly rates: RateCard,
    private readonly printer: ReceiptPrinter,
  ) {}

  checkIn(bikeId: number, hours: number): string {
    const euros = this.rates.price(hours);
    return this.printer.print(bikeId, euros);
  }
}

function openDesk(perHour: number, branch: string) {
  const rates = new RateCard(perHour);
  const printer = new ReceiptPrinter(branch);
  return new ReturnDesk(rates, printer);
}

function rentals(): string[] {
  const city = openDesk(3, "City Center");
  const airport = openDesk(5, "Airport");
  return [city.checkIn(7, 3), airport.checkIn(7, 3)];
}

Transcript, step by step

Problem

Step 1: A desk that builds its collaborators Before

This is a bike rental return desk. Rate Card holds the euros per hour and multiplies them by the hours. Receipt Printer holds a branch name and returns a line with the branch, the bike and the euros. Return Desk creates both in its own constructor, with three euros and City Center written right there. Check in asks the rate card for the price and tells the printer to print it. At the bottom, the rentals function checks in bike seven. It was out for three hours. So the receipt reads City Center, bike seven, nine euros. With one branch, a desk that builds its own rate card and printer was the shortest path, and callers knew nothing.

Step 2: An airport branch Change request

Now a second branch opens at the airport, and it charges five euros an hour and prints Airport on its receipts. Both desks run in the same program, so the quick way gives Return Desk's constructor a branch name. Inside, an if builds the airport rate card and printer, and the else keeps the city ones. tsc is happy, and the rentals function checks in bike seven at each desk. City Center's receipt says nine euros. The Airport's receipt says fifteen.

Step 3: The desk picks its own collaborators Code smell

Return Desk calls the rate card and the printer to do part of its job, so each one is a collaborator. Return Desk holds the check-in rule, yet it also picks its rate card and printer, because it creates its collaborators inside. It picks the branch here, builds the airport collaborators here, and builds the city collaborators here. Check in didn't change, yet Return Desk was edited for a change that's only about prices and names. And a third branch means one more else if, in the same constructor.

Step 4: The else hides a typo Code smell

Worse, here the rentals function passes Airport with a capital A. What does that desk charge? Three euros an hour, the city rate, because this comparison is case-sensitive and the else catches it. So the airport desk returns a City Center receipt for nine euros.

Step 5: Dependency injection Principle

The desk should get its rate card and printer from whoever builds it. A class that receives its collaborators from outside, instead of creating them, uses dependency injection. So the rentals function would hand the desk its rate card and printer, and the desk would only use them.

Refactor

Step 6: Refactor first Back it out

So we take the branch parameter and the if back out, and the desk serves City Center alone again. We refactor first in small steps with the same receipt each time, and the airport comes back at the end.

Step 7: Pass the rate card in Change Function Declaration

Return Desk's constructor gets a Rate Card parameter and stores it, so it no longer creates one. Because the constructor's signature changes, the move is called Change Function Declaration. When a constructor receives a collaborator this way, it's called constructor injection. The line that builds the rate card with three moved into the rentals function, which passes it in, so the receipt stays.

Step 8: Pass the printer in Change Function Declaration

The same constructor injection again for the printer, so the constructor takes a Receipt Printer too. Private readonly in front of a parameter makes it a field as well, and TypeScript calls that a parameter property. So the two field lines and both assignments are gone, the same way Rate Card already stores its rate. Now the constructor only stores what it's given, so it names no rate and no branch. The rentals function builds the printer with City Center and passes both collaborators in, and the receipt hasn't changed.

Step 9: One function opens a desk Extract Function

The airport would need a copy of those three build lines, so we extract them into a function, open desk. It takes the rate and the branch name, builds both collaborators from them, and returns a new desk. A function whose only job is to build and return an object is called a factory function. The rentals function calls open desk with three and City Center, and the receipt is still the same. The cost is one more function to read, and each new setting becomes one more parameter here.

Step 10: One place wires the collaborators Composition root

Something still has to create the objects and wire them, so open desk and the rentals function do it. The code that builds and wires every object, open desk and the rentals function, is called the composition root. It's the only code that knows the rate and the branch name. Return Desk, meanwhile, keeps the check-in rule and nothing else.

Payoff

Step 11: The airport, take two Payoff

So the airport request from the start can come back in now. The rentals function calls open desk with five and Airport, so a second desk opens beside the city one. City Center's receipt still says nine euros. The Airport's receipt still says fifteen, the same as the quick version.

Step 12: The desk did not change Payoff

Return Desk didn't change at all to add the airport. The constructor only stores its collaborators now. Both branches' collaborators are built here, from the arguments. Return Desk keeps the check-in rule and the caller chooses the collaborators, so a new branch is one more call. And a misspelled branch name can't fall into the wrong prices, because there's no name left to compare. You might think dependency injection needs an interface. It doesn't, because both collaborators stay concrete classes here, and only their creation moved. But now the composition root must know every collaborator, so each new branch is one more open desk call by hand.

Recap

Step 13: Where we started, where we ended Before / after

On the left, Return Desk created its rate card and printer itself, with three and City Center written in its constructor. So a second branch meant editing the desk, even though its check-in rule stayed the same. On the right, the desk receives both collaborators as parameter properties and only stores them. And open desk, in the composition root, builds each branch's collaborators from its arguments.

Step 14: When to reach for it Takeaways

Reach for it when one class must run with different collaborators in the same program, like two branches or two currencies. Skip it when a class has one setup that no caller will ever change. Skip it too when the collaborator is a plain array it fills, because passing it in only moves the creation outward. Spot it by a constructor that builds configured objects with values written inside, or takes a flag to pick them. And Node's Console class works this way, because it takes the streams it writes to as constructor arguments.