Your Domain Doesn't Know About PostgreSQL (And It Shouldn't)

Published: (March 25, 2026 at 07:32 AM EDT)
4 min read
Source: Dev.to

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 correctly

Typical outcomes:

  1. Spin up a test database and write a long fixture to test a few lines of discount logic.
  2. Mock SQLAlchemy aggressively, ending up with a test that no longer verifies real behavior.
  3. 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 total

This 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.9

Three lines, zero fixtures, zero mocks, zero database. The test runs in milliseconds.

Why the Separation Matters

Most backend codebases conflate two concerns:

  1. What the software does – the rules and calculations that define the product.
  2. 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.

0 views
Back to Blog

Related posts

Read more »

Data-Driven Architecture

The layering problem Applications often have many layers, such as repositories and ORMs, due to patterns like MVVM, MVC, and the Hexagonal architecture. My mai...