Your business logic shouldn't care whether you're using PostgreSQL, MySQL, or a folder full of text files. If it does that's not a minor code smell. That's an architectural problem.
Let me show you what it looks like in a real codebase.
A Simple Example That's Not Actually Simple
Say you're building an order management system. An order has items, a total, and a status. When an order is placed, you validate it, apply a discount if the customer qualifies, and save it.
Here's what that service looks like in most codebases:
from sqlalchemy.orm import Session
from app.models import Order, Customer
from app.db import get_db
from fastapi import HTTPException
import os
class OrderService:
def place_order(self, customer_id: int, items: list, db: Session):
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
total = sum(item["price"] * item["qty"] for item in items)
if customer.loyalty_points > 500:
total *= 0.9 # 10% discount
order = Order(
customer_id=customer_id,
total=total,
status="pending",
items=items
)
db.add(order)
db.commit()
db.refresh(order)
if os.getenv("NOTIFY_SERVICE_ENABLED") == "true":
# fire and forget
pass
return {"order_id": order.id, "total": total, "status": "pending"}
This is typical. This also quietly breaks your codebase not today, but over the next twelve months.
Look at what this function knows about:
- SQLAlchemy sessions
- FastAPI HTTP responses and status codes
- ORM model internals
- Environment variables
- Raw JSON dict shapes
This is supposed to be a business rule function. Its job: decide if an order is valid and how to price it. That's it. But it's doing five other jobs simultaneously and it's become impossible to test without all five of those jobs being present.
What Happens When You Try to Test It
def test_loyalty_discount_applied():
service = OrderService()
# ...now what?
# We need a real db Session
# We need real Customer rows in that database
# We need FastAPI's exception handling wired up
# We need environment variables set correctly
You end up with one of three outcomes:
- You spin up a test database and write a 60-line fixture to test 3 lines of discount logic.
- You mock SQLAlchemy so aggressively the test no longer tests anything real.
- You don't write the test at all.
Most teams land at option 3. Not because they're lazy. Because the code made it too hard to do otherwise.
The Domain Is Buried
Here's the actual business rule hiding in that function:
def calculate_order_total(items: list, loyalty_points: int) -> float:
total = sum(item["price"] * item["qty"] for item in items)
if loyalty_points > 500:
total *= 0.9
return total
That's the domain. That's the logic that matters to the business. The database call, the HTTP response, the env var check that's infrastructure. Scaffolding. It should be invisible to this calculation.
But in the original code, you can't separate them. Domain and infrastructure are fused.
It Gets Worse as the App Grows
Month six: product says "we need bulk orders from a CSV upload." The OrderService expects db: Session and raises HTTPException. Do you fake an HTTP request to process a CSV?
Month twelve: "We're migrating to MongoDB." You open the service and find SQLAlchemy queries in the service layer, the domain objects, the validators.
Month eighteen: a new microservice needs to share the pricing rules. You can't extract them without also extracting the ORM models and HTTP exception handling. So you copy-paste. Now you have two copies of the same business rule.
This is how legacy code is born. Not from big decisions. From a hundred small ones.
What "Clean" Actually Looks Like
The same concept, with nothing leaked:
from dataclasses import dataclass
from typing import List
@dataclass
class OrderItem:
product_id: str
price: float
quantity: int
@dataclass
class Order:
customer_id: str
items: List[OrderItem]
loyalty_points: int
def calculate_total(self) -> float:
total = sum(item.price * item.quantity for item in self.items)
if self.loyalty_points > 500:
total *= 0.9
return total
def is_valid(self) -> bool:
return len(self.items) > 0 and all(item.quantity > 0 for item in self.items)
No SQLAlchemy. No FastAPI. No os.getenv. No dict access.
Testing it:
def test_loyalty_discount():
order = Order(
customer_id="c-1",
items=[OrderItem("p-1", 100.0, 2)],
loyalty_points=600
)
assert order.calculate_total() == 180.0 # 200 * 0.9
Three lines. Zero fixtures. Zero mocks. Zero database. Runs in milliseconds.
The Core Problem
Most backend codebases conflate two things:
- What the software does the rules and calculations that make your product what it is
- How the software does it the database, HTTP framework, queues
The domain is the what. Everything else is the how.
When they're tangled, you can't change either one independently. You can't swap PostgreSQL without touching your business rules. You can't test your business rules without booting a database. You can't share logic between services without dragging the whole infrastructure layer with it.
Your domain shouldn't know about PostgreSQL. If it does, that's an architectural decision made implicitly and you'll be paying for it for a long time.
What Comes Next
How do you structure a codebase so this separation stays in place? Not as a one-time refactor, but as an ongoing constraint that's hard to violate accidentally?
That's what ports and adapters give you. Formal boundaries, explicitly named, consistently enforced. That's Post 2 in this series.
For now: open your own OrderService, UserService, whatever your equivalent is. Count how many external dependencies it imports that have nothing to do with business rules.
If the number is more than zero you have the problem. You're in very good company. And there's a clean way out.
Top comments (0)