Skip to main content
Clean Architecture 70 XP · 7 min

Architectural Boundaries: Drawing Lines

Architecture draws lines between what matters (business rules) and what doesn't (technical details) — these lines protect core business logic from changes in external tools.

Showing
Ad (728×90)

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.

Done with this lesson?

Mark it complete to earn XP and track your progress.