Stories & Processes
Separating IO to survive my own architecture
At the startup I worked for last year, thousands of lines of code were produced every day by many people on different levels of experience. I pushed for some conventions, but noticed that as soon as they were too elaborate, they'd easily be ignored by both developers and AI agents. So I needed to answer; if there was one convention I can enforce, what would it be? My answer was; separating business logic from IO.

Back in Amsterdam our lead developer taught us the power of pure functions and dependency injection. We could see code transform from scattered bags of functionalities to testable, scalable and something that I later discovered is the most important utility of good architecture in code to me; comprehensibility (how many things you have to hold in your head). All these principles were valuable, but it's that one principle he shared with us that stuck with me; keep business logic out of the IO.
What is meant with 'separating IO'?
To be honest, in that last venture, things were moving at such an insane pace that I wasn't able to find the time to properly explain this to my team back then. So here it is, almost a year later.
And real quick, I'll define what I mean with 'IO';
IO is any code that interacts with the world outside of your application (eg. databases, http, files, logging)
If you're anything like me, I need to see it, rather than it be explained. So, below is a simple example of the 'before';
@app.post("/users")
def create_user(req: CreateUserRequest, db: Session = Depends(get_db)):
# Database (IO) logic in the HTTP (IO) layer
if db.query(User).filter(User.email == req.email).first():
raise HTTPException(409, "email taken")
# Logic on how creating users is done in the HTTP (IO) layer
role = "admin" if req.email.endswith("@acme.internal") else "member"
trial_ends = datetime.utcnow() + timedelta(days=30 if role == "member" else 0)
user = User(email=req.email, role=role, trial_ends=trial_ends)
# More DB (IO) specific logic and syntax
db.add(user)
db.commit()
return user
Though this is a very simple example, let me highlight some pains I see that would be exacerbated on larger scales.
- Comprehension load: I need to read and grasp HTTP, database and platform specific logic in one section.
- Conventions over enforcements: What happens to a new user if they're a member is now something I need to remember everywhere in the platform, rather than enforcing it somewhere (which also increases the comprehension load of the application).
- Reusability: Chances are some other IO endpoint will need to create a user (eg. NATS/Kafka events, migration script, CLI command, etc.), now you'd need to duplicate and maintain the create-user logic (and you'd need to remember to do so when changing that logic, again, increasing comprehension load)
- Testability: Many test pipelines I've seen run 10+ minutes. Why? Because imagine testing this; you'd have to mock the HTTP and mock up a database. Which is tedious, so often devs just spin up a whole test database per test. The database and HTTP don't need testing, you didn't write it. You need to test your logic. Those tests can be run in less than a millisecond.
Below is the minimal change I would make that would mitigate the issues;
def new_user(email: str, now: datetime) -> User:
# What a user *is* is now decided in exactly one place.
# Note that there's no IO related logic, just pure business logic.
role = "admin" if email.endswith("@acme.internal") else "member"
trial_ends = now + timedelta(days=30 if role == "member" else 0)
return User(email=email, role=role, trial_ends=trial_ends)
@app.post("/users")
def create_user(req: CreateUserRequest, db: Session = Depends(get_db)):
# This layer now calls the business logic and handles all IO.
if db.query(User).filter(User.email == req.email).first():
raise HTTPException(409, "email taken")
user = new_user(req.email, datetime.utcnow())
db.add(user)
db.commit()
return user
One function moved. That's all. The endpoint didn't get shorter, but it no longer contains a decision--read it top to bottom and it's a sequence of IO calls with one rule in the middle. The rules of what a user looks like are in one place, reusable from a CLI or a Kafka consumer, and testable with two arguments and no mocks. It's not perfect, but that's the whole point; this is a low hanging architectural fruit, not the whole tree.
My whole architecture tree, for the people interested.
@app.post("/users")
def create_user(
req: CreateUserRequest,
repo: UserRepository = Depends(get_user_repository),
clock: Clock = Depends(get_clock),
):
try:
user = user_service.create_user(repo, clock, req.email)
except EmailTaken:
raise HTTPException(409, "email taken")
return user
def create_user(repo: UserRepository, clock: Clock, email: str) -> User:
if repo.exists_by_email(email):
raise EmailTaken(email)
user = build_new_user(email, clock())
repo.add(user)
repo.commit()
return user
class UserRepository:
def __init__(self, db: Session):
self.db = db
def exists_by_email(self, email: str) -> bool:
return self.db.query(User).filter(User.email == email).first() is not None
def add(self, user: User) -> None:
self.db.add(user)
def commit(self) -> None:
self.db.commit()
Technically the time/clock is IO. Though, I'd say you could go either way on whether to inject the clock, it hurts readability, but it does make testing a bit easier.
This is the part I actually bought it for. Back in Amsterdam, before I learned this principle, writing tests was an absolute pain. Afterwards the suites read like 'with this data, assert this outcome', rather than a whole wall of mocks and patches before getting to the part that matters.
The benefit I found out it had later
I separated IO for testability and reusability, but it helped in something I didn't expect.
I like exploring best practices in software, and in my Similarity project I had the chance to really nerd out on how to structure my code. But recently I came to discover I made a pretty grave error in my architecture; I had totally misunderstood and mixed up hexagonal and the Onion architecture. I went wild with dependency injection (DI) and everything became a swappable component. I thought DI was free--why not make it a swappable component if it could be? But I found out that DI and creating many components and abstractions come at a great comprehension cost.
I had a few dozen components. I needed three.
That's the scale of how wrong I was, in a project that already had a couple hundred users on it. The refactor took one afternoon.
For testability sake, I made sure to separate IO from my business logic in the application. Turns out that one decision also made the refactor able to be done in one afternoon.
Though the orchestration of my application was a mess, all the business logic was as pure as it could be. So the rules and core logic never needed to change or be pried out, it just needed to be called from different places. Refactoring is a nightmare when you need to pry logic out of a 300 line HTTP endpoint. Or reading that the HTTP endpoint creates users one way, but the email campaign creates it another way--which one is it? I've been in both of those places. But once you can pass around core rules and transformations like variables, refactoring becomes trivial.

Had the logic been threaded through the IO, that afternoon would have been a rewrite instead of a refactor. I truly don't think I would be able to wrap my head around the scale of such a refactor. So, it might have killed that application, or made the development slow down to a crawl at best.
Conclusion
Separating IO is the principle that lets you afford to be wrong about every other architectural decision you make. And you will be wrong; the shape of a system is the part you understand last, and it keeps changing as the needs change or become more clear.
So no, it won't solve all your problems, and I don't think there's one architecture that fits all. It's just the cheapest thing I know of that's still working after you've picked a worse one.