Skip to main content

Ask for Your Tools: Decoupling a Python Program Step by Step (Part 1)

Updated 6 min readMarkdown
Ask for Your Tools: Decoupling a Python Program Step by Step (Part 1)

Decoupling means separating the code that decides what should happen from the code that talks to the outside world: files, mail servers, SMS providers. When the two are tangled together, every change is harder than it needs to be, and you can't test anything without the real thing.

In this post I take a tiny Python online store that works but is tangled, and pull it apart one small step at a time, running it after every change. I've left my mistakes in, because that's where most of the learning was.

The starting point

The store places orders, saves them, and emails the customer. It ran fine:

class OrderService:
    def place_order(self, order: StandardOrder | ExpressOrder) -> Decimal:
        if isinstance(order, StandardOrder):
            total = order.amount + Decimal("5")
        else:
            total = order.amount + Decimal("15")

        storage = FileOrderStorage()
        storage.connect("orders.txt")
        storage.save(order.order_id, total)

        email_sender = SmtpEmailSender()
        email_sender.login("shop@example.com", "super_secret_password")
        email_sender.send(order.customer_email, ...)
        return total

My first reaction was "what's wrong with this?" It took me a while to see it. Nothing is broken, but switching from email to SMS means editing order logic. You can't test place_order without a real file and a real mail server. A password sits inside business code. And the log said "Logging in to SMTP server" three times, once per call.

The idea that made it click

I was getting lost in terms like coupling, dependency injection and Protocols, until I realised they're all parts of one idea:

A function shouldn't build its own tools. It should ask for them.

Think of a lamp. It doesn't generate its own electricity. It plugs into a socket and trusts that power comes out, and the socket doesn't care what's plugged in, as long as the plug fits. My place_order was building its own power plant on every call. Once I had that picture, the rest fell into place.

Steps 1–2: ask for your tools

The exercise that helped most was sorting every line of place_order into two piles: build/configure (create the object, connect, log in) and use (save, send). The "use" lines stayed. The "build" lines moved to main(), where they run once:

def main() -> None:
    storage = FileOrderStorage()
    storage.connect("orders.txt")
    email_sender = SmtpEmailSender()
    email_sender.login("shop@example.com", os.environ["SMTP_PASSWORD"])

    place_order(standard_order, storage, email_sender)
    place_order(express_order, storage, email_sender)

Logins dropped from three to one. Finally I could see what "separating creation from usage" means.

That's all dependency injection is: instead of a function creating what it depends on, those dependencies are passed in ("injected") from outside. Here, main() builds the storage and sender once and hands them to place_order.

My first slip: I passed standard_order into both calls. The program ran fine and told me the express order cost 55 instead of 95. Lesson learned: check the output after every small step, not just whether it runs.

Step 3: learning what a Protocol actually is

store.py still imported the concrete classes for its type hints, so it was still welded to them. I replaced them with Protocols that list only what the store calls:

class OrderStorage(Protocol):
    def save(self, order_id: str, total: Decimal) -> None: ...
    def delete(self, order_id: str) -> None: ...

class Sender(Protocol):
    def send(self, to: str, subject: str, body: str) -> None: ...

I had a lot of questions here. Does the Protocol run any code? (No, it's only a job description.) How does FileOrderStorage "join" it without inheriting from it? (It matches on shape: the same method name, parameter names, types and return type.) Why aren't connect and login in it? (Setup isn't usage.) Working through these one at a time, with tiny examples, did more for me than any definition.

Step 4: a class that wasn't holding anything

OrderService had no fields, and self was never used, so I made it plain functions. The output stayed identical, line for line. That's when "refactoring changes structure, not behaviour" stopped being a phrase I'd read and became something I'd checked myself.

Steps 5–6: the kind was hiding in the class name

StandardOrder and ExpressOrder had the same fields. The only difference was the class name, which is why the code needed isinstance. When I made the kind a field, one class was enough:

class OrderType(StrEnum):
    STANDARD = "Standard"
    EXPRESS = "Express"

SHIPPING_COSTS = {
    OrderType.STANDARD: Decimal("5"),
    OrderType.EXPRESS: Decimal("15"),
}

@dataclass
class Order:
    order_id: str
    customer_email: str
    amount: Decimal
    order_type: OrderType

The label prints itself (f"Placing {order.order_type} order"), and the if/else becomes a lookup: order.amount + SHIPPING_COSTS[order.order_type]. I tried this first on a throwaway coffee-sizes example before touching the real code, and I'd recommend that to anyone.

This was also the step where I made the most mistakes. I deleted the line that calculated total along with the old if. I lost the log messages. I copied a print into cancel_order that still said "Placing". And for a while my dict held the wrong numbers, so the totals came out as 53 and 85.

mypy said "Success" every single time. That surprised me. It checks that types fit together, but it has no idea what your shipping costs should be. Only reading the output caught those mistakes, and that's exactly why Part 2 is about tests.

The mental model I'm keeping

By the end, the files had settled into three roles:

  • Core (store.py): business rules, plus Protocols describing what it needs. It never builds tools.
  • Adapters (storage.py, email_service.py): they know how to talk to the outside world, not why.
  • Wiring (main.py): it picks the tools, sets them up, and hands them over. Short and boring, and the only file that knows everything.

The core and the adapters never import each other. They only meet in main.py.

Now when I read my own code, I look for a few signs: functions that create their own clients or connections, .login() or .connect() inside business logic, secrets in code, near-identical classes told apart with isinstance, and classes that never use self.

I also had to unlearn an overreaction. At one point I wondered whether every import between my files was a smell. It isn't. Importing your own data classes and helpers is normal. I'd only refactor where it actually hurts: when you want to test without the real thing, or when you're really going to swap a provider.

What's next

In Part 2, I'll put this to the test. I'll write fakes so the logic can be tested without a file or a mail server, and then switch from email to SMS without touching store.py at all. If the refactor really worked, that second part should be easy. We'll see.

No comments yet. Be the first to comment.