Your Domain Doesn't Know About PostgreSQL (And It Shouldn't)
Source: Dev.to
The Problem: Business Logic Coupled to Infrastructure
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—it’s an architectural problem.
A Tangled Example
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 function knows about:
- SQLAlchemy sessions
- FastAPI HTTP responses and status codes
- ORM model internals
- Environment variables
- Raw JSON dict shapes
Its sole business responsibility is to decide if an order is valid and how to price it. Instead, it does five other jobs simultaneously, making it hard to test.
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 correctlyTypical outcomes:
- Spin up a test database and write a long fixture to test a few lines of discount logic.
- Mock SQLAlchemy aggressively, ending up with a test that no longer verifies real behavior.
- Skip the test altogether (most teams).
The last option isn’t laziness; it’s a symptom of code that’s too hard to test.
Extracting the Pure Business Rule
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 totalThis is the domain logic that matters to the business. All the surrounding database calls, HTTP handling, and environment checks are infrastructure concerns and should be invisible to the calculation.
A Clean Domain Model
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, FastAPI, os.getenv, or raw dictionaries—just pure domain concepts.
Testing the Domain Logic
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.9Three lines, zero fixtures, zero mocks, zero database. The test runs in milliseconds.
Why the Separation Matters
Most backend codebases conflate two concerns:
- What the software does – the rules and calculations that define the product.
- How the software does it – databases, HTTP frameworks, queues, etc.
When these concerns are tangled:
- You can’t swap PostgreSQL without touching business rules.
- You can’t test business rules without booting a database.
- You can’t share logic between services without dragging the entire infrastructure layer.
Your domain shouldn’t know about PostgreSQL (or any specific storage). If it does, you’ve made an implicit architectural decision that will cost you over time.
Maintaining the Separation
Ports and adapters (a.k.a. Hexagonal Architecture) provide formal boundaries that are explicitly named and consistently enforced. They keep the domain pure while allowing the outer layers to change independently.
Actionable step: Open any service class in your codebase (e.g., OrderService, UserService). Count how many external dependencies it imports that have nothing to do with business rules. If the count is greater than zero, you have the problem described here. You’re in good company, and there’s a clean way out.