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.