Skip to main content
Clean Architecture 70 XP · 7 min

Keep Options Open

A good architect maximizes the number of decisions not yet made — separating policy from details so database and framework choices can be postponed until more is known.

Showing
Ad (728×90)

Why this matters

Every software system can be divided into two categories: policy and details. Policy contains the business rules and procedures — the true value of the system. Details are the things that let humans, machines, and other systems communicate with the policy: databases, web frameworks, I/O devices. Details are irrelevant to the policy. They should never influence it.

A good architect recognizes this and maximizes the number of decisions not yet made. The longer you delay committing to a database technology, the more you know about traffic patterns, query shapes, and consistency needs. A system whose business logic works with an in-memory repository proves that the database is a detail — you can plug in the real one when the choice is well-informed.

The problem

Business logic class that directly imports the ORM, queries a DB model, and formats HTML — policy and details welded together, impossible to test without a real database.

Bad

from sqlalchemy.orm import Session
from models import User, Subscription

class UserService:
    def __init__(self, session: Session):
        self.session = session

    def get_welcome_page(self, user_id: int) -> str:
        user = self.session.query(User).filter(User.id == user_id).first()
        if not user:
            return "<h1>Not found</h1>"
        sub = self.session.query(Subscription).filter(
            Subscription.user_id == user_id, Subscription.active == True
        ).first()
        tier = "Premium" if sub else "Free"
        return f"<h1>Welcome {user.name} ({tier})</h1>"

# Test requires a real database with the full ORM schema.
# Switching to MongoDB: rewrite UserService entirely.
# The database is not a detail — it is a core dependency.
import { DataSource } from "typeorm";
import { UserEntity } from "./entities/User";
import { SubscriptionEntity } from "./entities/Subscription";

export class UserService {
  constructor(private ds: DataSource) {}

  async getWelcomePage(userId: number): Promise {
    const user = await this.ds.getRepository(UserEntity)
      .findOne({ where: { id: userId } });
    if (!user) return "<h1>Not found</h1>";
    const sub = await this.ds.getRepository(SubscriptionEntity)
      .findOne({ where: { userId, active: true } });
    const tier = sub ? "Premium" : "Free";
    return `<h1>Welcome ${user.name} (${tier})</h1>`;
  }
}
// Test requires TypeORM DataSource with migrations.
// The option to swap the database has already been closed.

The solution

UserService depends only on an abstract repository — the database is a plugin you choose when you're ready.

Good

from abc import ABC, abstractmethod
from dataclasses import dataclass

@dataclass
class UserProfile:
    name: str
    tier: str  # "free" | "premium"

class UserRepository(ABC):
    @abstractmethod
    def get_profile(self, user_id: int) -> UserProfile | None: ...

class UserService:
    def __init__(self, repo: UserRepository):
        self._repo = repo

    def get_welcome_message(self, user_id: int) -> str:
        profile = self._repo.get_profile(user_id)
        if not profile:
            return "User not found"
        label = "Premium" if profile.tier == "premium" else "Free"
        return f"Welcome {profile.name} ({label})"

# Test with zero infrastructure:
class InMemoryUserRepo(UserRepository):
    def __init__(self, data): self._data = data
    def get_profile(self, uid): return self._data.get(uid)

# The database option is still open. Commit when you know enough.
export interface UserProfile { name: string; tier: "free" | "premium"; }

export interface UserRepository {
  getProfile(userId: number): Promise;
}

export class UserService {
  constructor(private repo: UserRepository) {}

  async getWelcomeMessage(userId: number): Promise {
    const profile = await this.repo.getProfile(userId);
    if (!profile) return "User not found";
    const label = profile.tier === "premium" ? "Premium" : "Free";
    return `Welcome ${profile.name} (${label})`;
  }
}

// In tests — no DB, no ORM, no network:
const stub: UserRepository = {
  getProfile: async (id) => id === 1 ? { name: "Alice", tier: "premium" } : null,
};
// In production — TypeORM or Prisma adapter implements UserRepository.
// Decision deferred. Option kept open.

Key takeaway

If your business logic can't run without a database connection, the database is not a detail — it's a core dependency you never questioned. Separate policy from details, and delay committing to details until you have enough information to choose wisely.

Done with this lesson?

Mark it complete to earn XP and track your progress.