# 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:

```python
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:

```python
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:

```python
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:

```python
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.

