I'll Be Backend
Design patterns · Python

Dependency injection: stop creating collaborators inside

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

15 steps5 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 Python video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–5

Refactor

Steps 6–10

Payoff

Steps 11–12

Python bonus

Step 13

Recap

Steps 14–15

The code: before and after

Before

@dataclass(frozen=True)
class RateCard:
    per_hour: int

    def price(self, hours: int) -> int:
        return hours * self.per_hour


@dataclass(frozen=True)
class ReceiptPrinter:
    branch: str

    def print(self, bike_id: int, euros: int) -> None:
        print(f"{self.branch}: bike {bike_id}, {euros} EUR")


class ReturnDesk:
    def __init__(self) -> None:
        self.rates = RateCard(3)
        self.printer = ReceiptPrinter("City Center")

    def check_in(self, bike_id: int, hours: int) -> None:
        euros = self.rates.price(hours)
        self.printer.print(bike_id, euros)


def main() -> None:
    ReturnDesk().check_in(7, 3)


if __name__ == "__main__":
    main()

After

@dataclass(frozen=True)
class RateCard:
    per_hour: int

    def price(self, hours: int) -> int:
        return hours * self.per_hour


@dataclass(frozen=True)
class ReceiptPrinter:
    branch: str

    def print(self, bike_id: int, euros: int) -> None:
        print(f"{self.branch}: bike {bike_id}, {euros} EUR")


@dataclass(frozen=True)
class ReturnDesk:
    rates: RateCard
    printer: ReceiptPrinter

    def check_in(self, bike_id: int, hours: int) -> None:
        euros = self.rates.price(hours)
        self.printer.print(bike_id, euros)


def open_desk(per_hour: int, branch: str) -> ReturnDesk:
    rates = RateCard(per_hour)
    printer = ReceiptPrinter(branch)
    return ReturnDesk(rates, printer)


def main() -> None:
    open_desk(3, "City Center").check_in(7, 3)
    open_desk(5, "Airport").check_in(7, 3)


if __name__ == "__main__":
    main()

Transcript, step by step

Problem

Step 1: A desk that builds its collaborators Before

This is a bike rental return desk, and Rate Card holds the euros per hour and multiplies them by the hours. Receipt Printer holds a branch name and prints the branch, the bike and the euros. Return Desk creates both in its own init, 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. Main checks in bike seven after 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 init a branch name. Inside, an if builds the airport rate card and printer, and the else keeps the city ones. Mypy is happy, and main checks in bike seven at each desk. The receipts say nine euros at City Center, then fifteen at Airport.

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 elif, in the same init.

Step 4: The else hides a typo Code smell

Worse, here main 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.

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. In plain words, whoever builds the desk hands it the rate card and the printer, and the desk only uses 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 init gets a Rate Card parameter and stores it, so it no longer creates one. This move is Change Function Declaration, and when a constructor gains a collaborator this way, it's called constructor injection. The type hint on that parameter tells mypy and the reader that every desk needs a Rate Card. The line that built the rate card with three moved into main, which passes it in, and the receipt is the same.

Step 8: Pass the printer in Change Function Declaration

The same constructor injection again for the printer, so the init takes a Receipt Printer too. Now it only stores what it's given, so it names no rate and no branch. Main 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 main's 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. Main calls open desk with three and City Center, and the receipt is still the same. Each new setting, such as a currency, adds one more parameter to open desk.

Step 10: One place wires the collaborators Composition root

Something still has to create the objects and wire them together, so main and open desk do it, near the start. The one place that builds and wires every object 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. Main calls open desk with five and Airport, and checks in bike seven for three hours. It prints nine euros at City Center and fifteen at Airport, the same two receipts as the quick version.

Step 12: The desk did not change Payoff

Return Desk didn't change at all to add the airport. Where the if stood, init only stores its collaborators, and 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 injection needs an interface or a Protocol. It doesn't, because both collaborators stay concrete classes here, and only their creation moved. But now the composition root has to know every collaborator, and it builds each desk by hand.

Python bonus

Step 13: ReturnDesk as a dataclass Python bonus

But Return Desk's init now only stores the two collaborators it's given, and spells each name three times. The dataclass decorator writes that init from the field annotations, the way Rate Card and Receipt Printer already get theirs. So the desk lists its two collaborators as fields, and the call in open desk stays exactly the same.

Recap

Step 14: 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 init. 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 fields and only stores them. And open desk, in the composition root, builds each branch's collaborators from its arguments.

Step 15: 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 list it fills, because passing it in only moves the creation outward. Spot it by an init that builds configured objects with values written inside, or takes a flag to pick them. And Python's own Stream Handler in logging works this way, because it takes the stream it writes to as an argument.