Skip to main content

Sign in to CleanKata

Track your progress, earn XP, and unlock every lesson.

By signing in you agree to our Terms of Use and Privacy Policy.

Clean Architecture70 XP7 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.

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.

🔧 Some exercises may still have errors. If something seems wrong, use the Feedback button (bottom-right of the page) to report it — it helps us fix it fast.

Hint: An architectural boundary is any place where you can draw a line and say: 'Nothing on this side knows about anything on that side.'

✗ Your version

Architectural Boundaries: Drawing Lines — CleanKata — CleanKata