Why this matters
Drawing a line means deciding which module knows about which. The most important lines are drawn between the core business rules and everything else — databases, frameworks, user interfaces, external APIs. These are details. Details are things that can change. Business rules are things that must not change just because a detail changes.
A database schema change should never require touching business logic. A switch from REST to GraphQL should never ripple into a discount calculation. An upgrade from one email service to another should never cause a test failure in a financial rule. When these things happen — when a schema migration touches a use case, or an API change requires modifying an entity — it means a line was not drawn where it should have been.
The problem
InvoiceService knows about three unrelated external systems — pandas, PostgreSQL, and SMTP — meaning four systems must be running to test a single business rule.
Bad
import pandas as pd
from sqlalchemy.orm import Session
from models import InvoiceORM
import smtplib, ssl
class InvoiceService:
def __init__(self, db: Session, smtp_host: str):
self.db, self.smtp = db, smtp_host
def generate_and_send(self, client_id: str) -> None:
invoices = self.db.query(InvoiceORM).filter(
InvoiceORM.client_id == client_id, InvoiceORM.paid == False
).all()
df = pd.DataFrame([{"id": i.id, "amount": i.amount} for i in invoices])
df.to_excel(f"/tmp/{client_id}_invoices.xlsx", index=False)
ctx = ssl.create_default_context()
with smtplib.SMTP_SSL(self.smtp, 465, context=ctx) as s:
s.login("user", "pass")
s.sendmail("billing@co.com", client_id, "See attachment")
# To test: need DB, filesystem, and SMTP server all running.
# Switching from Excel to PDF: touch InvoiceService.
# Switching from SMTP to SendGrid: touch InvoiceService.
import { DataSource } from "typeorm";
import ExcelJS from "exceljs";
import nodemailer from "nodemailer";
export class InvoiceService {
constructor(private ds: DataSource, private smtpConfig: SMTPConfig) {}
async generateAndSend(clientId: string): Promise {
const invoices = await this.ds.getRepository(InvoiceEntity)
.find({ where: { clientId, paid: false } });
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet("Invoices");
ws.addRows(invoices.map(i => [i.id, i.amount]));
const buffer = await wb.xlsx.writeBuffer();
const transport = nodemailer.createTransport(this.smtpConfig);
await transport.sendMail({ to: clientId, attachments: [{ content: buffer }] });
}
}
// No line between business rules and the three technical details.
// Test requires TypeORM, ExcelJS, and nodemailer all configured.
The solution
Three abstract ports — one per technical detail — draw three lines. InvoiceService depends on abstractions only. Each detail is independently swappable.
Good
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class Invoice:
id: str
client_id: str
amount: float
paid: bool
# Three ports — three lines at the architectural boundary
class InvoiceRepository(ABC):
@abstractmethod
def get_unpaid(self, client_id: str) -> list[Invoice]: ...
class ReportFormatter(ABC):
@abstractmethod
def format(self, invoices: list[Invoice]) -> bytes: ...
class EmailSender(ABC):
@abstractmethod
def send(self, to: str, attachment: bytes) -> None: ...
# Business rule — no imports of pandas, SQLAlchemy, or smtplib
class InvoiceService:
def __init__(self, repo: InvoiceRepository,
formatter: ReportFormatter, mailer: EmailSender):
self._repo, self._formatter, self._mailer = repo, formatter, mailer
def generate_and_send(self, client_id: str) -> None:
invoices = self._repo.get_unpaid(client_id)
report = self._formatter.format(invoices)
self._mailer.send(client_id, report)
# Swap Excel→PDF: replace ReportFormatter only — InvoiceService unchanged.
# Swap SMTP→SendGrid: replace EmailSender only — InvoiceService unchanged.
export interface Invoice { id: string; clientId: string; amount: number; paid: boolean; }
// Three ports — three lines
export interface InvoiceRepository { getUnpaid(clientId: string): Promise; }
export interface ReportFormatter { format(invoices: Invoice[]): Promise; }
export interface EmailSender { send(to: string, attachment: Buffer): Promise; }
// Business rule — no typeorm, no exceljs, no nodemailer
export class InvoiceService {
constructor(
private repo: InvoiceRepository,
private formatter: ReportFormatter,
private mailer: EmailSender,
) {}
async generateAndSend(clientId: string): Promise {
const invoices = await this.repo.getUnpaid(clientId);
const report = await this.formatter.format(invoices);
await this.mailer.send(clientId, report);
}
}
// In tests: stub all three interfaces with in-memory implementations.
// In production: TypeOrmInvoiceRepository | ExcelReportFormatter | SendGridEmailSender
Key takeaway
An architectural boundary is any place where you can draw a line and say: "Nothing on this side knows about anything on that side." Business rules live on one side; databases, frameworks, and I/O live on the other. The line is an abstract interface.