Why this matters
There are two kinds of business rules. Critical Business Rules are the rules that would exist even if the software didn't — a bank calculates loan interest whether the system runs on paper ledgers or cloud servers. These rules are the most valuable thing in the system. They must be isolated, protected, and untouched by anything else. Application Business Rules are use-case-specific rules that only exist because there is software — validation workflows, input transformation steps, orchestration logic.
Entities are the objects that embody Critical Business Rules. An Entity is a pure business concept: a Loan, an Order, an Invoice. It contains data and the rules that operate on that data. It knows nothing about databases, UIs, frameworks, or HTTP. It can be instantiated in a Python test file with no imports beyond the standard library. If an Entity imports SQLAlchemy, it has been corrupted. The framework has invaded the most stable, valuable part of the system.
The problem
A Loan class that has calculate_interest() but also save_to_db(), to_html(), and send_email_notification() — the critical business rule is buried with technical concerns it should never know about.
Bad
from sqlalchemy import Column, Float, Integer
from sqlalchemy.ext.declarative import declarative_base
import smtplib
from decimal import Decimal
Base = declarative_base()
class Loan(Base):
__tablename__ = "loans"
id = Column(Integer, primary_key=True)
principal = Column(Float)
rate = Column(Float)
term = Column(Integer)
def calculate_interest(self) -> float:
# Critical business rule — would exist on a paper ledger too
return self.principal * self.rate * self.term
def save_to_db(self, session):
session.add(self)
session.commit()
def to_html(self) -> str:
interest = self.calculate_interest()
return f"<p>Loan {self.id}: interest = {interest:.2f}</p>"
def send_email_notification(self, recipient: str):
smtp = smtplib.SMTP("localhost")
smtp.sendmail("bank@co.com", recipient,
f"Interest: {self.calculate_interest():.2f}")
# The critical rule is correct but corrupted.
# To test calculate_interest: SQLAlchemy, DB session, and smtplib must exist.
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
import nodemailer from "nodemailer";
@Entity()
export class Loan {
@PrimaryGeneratedColumn() id!: number;
@Column("float") principal!: number;
@Column("float") rate!: number;
@Column("int") term!: number;
calculateInterest(): number {
return this.principal * this.rate * this.term;
}
toHtml(): string {
return `<p>Loan ${this.id}: interest = ${this.calculateInterest().toFixed(2)}</p>`;
}
async sendEmailNotification(recipient: string): Promise {
const t = nodemailer.createTransport({ host: "localhost", port: 25 });
await t.sendMail({ from: "bank@co.com", to: recipient,
subject: "Loan interest", text: `${this.calculateInterest()}` });
}
}
// To test calculateInterest: TypeORM decorators require DB setup.
// The Entity has been corrupted by three technical concerns.
The solution
A pure Loan entity with only the critical business rules — no ORM, no HTML, no email. Persistence, presentation, and notification are separate concerns handled by separate classes.
Good
from decimal import Decimal
from dataclasses import dataclass
@dataclass
class Loan:
principal: Decimal
rate: Decimal # annual interest rate, e.g. Decimal("0.05") for 5%
term: int # years
def calculate_interest(self) -> Decimal:
"""Critical Business Rule — would be calculated on paper without software."""
return self.principal * self.rate * self.term
def monthly_payment(self) -> Decimal:
"""Another critical rule — depends only on principal, rate, and term."""
monthly_rate = self.rate / 12
n = self.term * 12
if monthly_rate == 0:
return self.principal / n
return self.principal * monthly_rate / (1 - (1 + monthly_rate) ** -n)
# Test with plain Python — zero infrastructure:
# loan = Loan(Decimal("10000"), Decimal("0.05"), 2)
# assert loan.calculate_interest() == Decimal("1000.00")
# Technical concerns are separate, isolated classes:
# class SqlLoanRepository(LoanRepository): ... — persistence
# class LoanPresenter: def to_html(loan: Loan) -> str: ... — presentation
# class LoanNotifier: async def send(loan: Loan, email: str): ... — notification
# The Entity knows none of them exist.
// Pure domain entity — no imports from any framework or library
export class Loan {
constructor(
readonly principal: number,
readonly rate: number, // annual interest rate, e.g. 0.05 for 5%
readonly term: number, // years
) {}
calculateInterest(): number {
// Critical Business Rule — exists in banking regardless of software
return this.principal * this.rate * this.term;
}
monthlyPayment(): number {
const monthlyRate = this.rate / 12;
const n = this.term * 12;
if (monthlyRate === 0) return this.principal / n;
return this.principal * monthlyRate / (1 - Math.pow(1 + monthlyRate, -n));
}
}
// Test with zero dependencies:
// const loan = new Loan(10_000, 0.05, 2);
// expect(loan.calculateInterest()).toBe(1000);
// Technical concerns live elsewhere:
// class TypeOrmLoanRepository implements LoanRepository { ... } — persistence
// class LoanPresenter { toHtml(loan: Loan): string { ... } } — presentation
// class LoanNotifier { async send(loan: Loan, email: string) } — notification
// The Entity imports nothing. Framework upgrades cannot corrupt it.
Key takeaway
An Entity is a business concept that exists regardless of technology. If it imports a framework, it has been corrupted. The Entity is the innermost layer of the system — it must know nothing about databases, UIs, or external services. Everything else depends on it. It depends on nothing.