I'll Be Backend
Principles · Python

Coupling: wrap a third-party library

Keep a vendor's library behind one class of yours, so a switch touches one class.

13 steps4 chapters~6 min at 1.25×#architecture#coupling#wrapper#adapter pattern
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–3

Refactor

Steps 4–9

Payoff

Steps 10–11

Recap

Steps 12–13

The code: before and after

Before

class BookingService:
    def __init__(self) -> None:
        self.pay = CardPayClient("desk-key")
        self.rates = {"Golf": 40, "Polo": 35}

    def book(self, car: str, days: int) -> str:
        total = self.rates[car] * days
        r = self.pay.charge(total * 100, "USD")
        if r.status != "succeeded":
            return "declined"
        return f"{car} booked, {total} USD"


class ReturnDesk:
    def __init__(self) -> None:
        self.client = CardPayClient("desk-key")

    def late_fee(self, car: str, hours: int) -> str:
        fee = hours * 15
        result = self.client.charge(fee * 100, "USD")
        if result.status != "succeeded":
            return "declined"
        return f"{car} late fee, {fee} USD"


def main() -> None:
    print(BookingService().book("Golf", 3))
    print(ReturnDesk().late_fee("Golf", 2))


if __name__ == "__main__":
    main()

After

from typing import Protocol


class Payments(Protocol):
    def charge(self, dollars: int) -> bool: ...


class CardPayPayments:
    def __init__(self, api_key: str) -> None:
        self.pay = CardPayClient(api_key)

    def charge(self, dollars: int) -> bool:
        r = self.pay.charge(dollars * 100, "USD")
        return r.status == "succeeded"


class PayLanePayments:
    def __init__(self, account: str) -> None:
        self.lane = PayLane(account)

    def charge(self, dollars: int) -> bool:
        try:
            self.lane.send(dollars, "USD")
        except PayLaneError:
            return False
        return True


class BookingService:
    def __init__(self, payments: Payments) -> None:
        self.payments = payments
        self.rates = {"Golf": 40, "Polo": 35}

    def book(self, car: str, days: int) -> str:
        total = self.rates[car] * days
        if not self.payments.charge(total):
            return "declined"
        return f"{car} booked, {total} USD"


class ReturnDesk:
    def __init__(self, payments: Payments) -> None:
        self.payments = payments

    def late_fee(self, car: str, hours: int) -> str:
        fee = hours * 15
        if not self.payments.charge(fee):
            return "declined"
        return f"{car} late fee, {fee} USD"


def main() -> None:
    payments = PayLanePayments("desk-account")
    print(BookingService(payments).book("Golf", 3))
    print(ReturnDesk(payments).late_fee("Golf", 2))


if __name__ == "__main__":
    main()

Transcript, step by step

Problem

Step 1: Two classes take money Before

This is the same car rental desk from last time, and now it takes card payments. Booking Service prices a rental and charges the card, and Return Desk charges a late fee of fifteen dollars an hour. Both call Card Pay, a third-party library, which is code another company writes and ships, that we call but cannot change. Each class keeps a Card Pay client here, calls charge in cents here, and checks the status string here. The main function is the caller, and the run prints Golf booked for a hundred and twenty dollars, then a Golf late fee of thirty. The library's own guide shows exactly these lines, so pasting them wherever money is taken was the shortest path.

Step 2: Finance moves to PayLane Change request

Finance moves card payments to Pay Lane next month. The quick edit swaps the field in both classes for a Pay Lane client with the desk account. Pay Lane takes dollars instead of cents and says no by raising an error, so each charge becomes a send inside a try block. The type check passes, and the run prints the same two lines as before.

Step 3: Coupled to the vendor Code smell

Coupling is how much one part of the code must change when another part changes. Here our business classes are coupled to a third-party library, because they use its class, its account, its units and its way of saying no. You can see the field here, the send call here, and the except here, once in each class. So a vendor switch edits every class that takes money, and here that's two classes, each with a new field, a new call, dollars in place of cents, and a try block in place of the status check.

Refactor

Step 4: Refactor first Back it out

So let's undo the edit, refactor first, and then switch. Both classes are back on Card Pay, and every step from here prints the same two lines.

Step 5: Name the charge Extract Method

First we extract a method in Booking Service called charge, which takes dollars and returns true when the card goes through. It holds the cents math, the call to the library, and the status check, and the leading underscore tells readers it's private to this class. Book now asks charge and returns declined when the answer is no, so the run prints the same lines.

Step 6: The library gets a class Extract Class

Next we extract a class, Card Pay Payments, and move the client field and the charge method into it. Its constructor takes the API key, and charge drops its underscore, because other classes will call it now. Booking Service holds one with the desk key and calls its charge method. A wrapper is one small class of ours that is the only code that calls the library. Return Desk still calls Card Pay itself, so this class isn't the only caller yet.

Step 7: Return desk uses it too Replace Inline Code with Function Call

Return Desk drops its own Card Pay client and holds a Card Pay Payments instead. Late fee replaces the call and the status check with one call to charge, the same way book does. Card Pay Payments is now the wrapper, because it is the only class that calls the Card Pay library.

Step 8: Name what the desk needs Extract Interface

We extract an interface, a Payments protocol, with the one charge method our desk needs. Card Pay Payments fits it because it has that method, and both fields are now annotated as Payments, so mypy checks the fit. Nothing about cents or status strings is left in that protocol, so another vendor can fit behind it.

Step 9: Pass the wrapper in Constructor Injection

Each service still built its own wrapper, so the choice of vendor sat inside two business classes. With constructor injection, a form of dependency injection, each class takes its Payments in the constructor instead. Main now builds one wrapper and passes it to both services. The run still prints the same two lines, Golf booked and the Golf late fee.

Payoff

Step 10: Switch to PayLane again Payoff

Now we make the switch to Pay Lane again. We add Pay Lane Payments, which fits the Payments protocol and takes the desk account in its constructor. Its charge sends dollars to Pay Lane and returns false when Pay Lane raises, or true when the send goes through. Main passes the new wrapper, and that is the only line outside it that changed. Booking Service, Return Desk and the Payments protocol did not change at all. The run prints the same two lines, now paid through Pay Lane.

Step 11: The vendor lives in one class Payoff

The Pay Lane field and its call and its except used to sit in both business classes. Now you can see the field here, the send call here, and the except here, once, inside the wrapper. This is the Adapter pattern, because each wrapper turns a vendor's interface into the one our code asked for. So a vendor switch now costs one new class and one line in main.

Recap

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

On the left, both business classes made a Card Pay client, charged it in cents, and checked its status string. On the right, Booking Service only knows the Payments protocol and asks it to charge dollars. Card Pay Payments is the one class that calls Card Pay, with the cents math and the status check inside it. So the vendor's names and units live in one class instead of in every class that takes money.

Step 13: When to reach for it Takeaways

Reach for it when a vendor library is called from more than one business class, or when a switch of vendors is likely. Skip it for libraries that are stable and everywhere, like the standard collections, because the extra class hides nothing worth hiding. You'll spot the need by vendor class names, keys, units or status strings showing up in more than one business class. Python already does this with the dbm module, which puts one interface in front of several database libraries.