Skip to main content
Clean Architecture 60 XP · 6 min

Device Independence

Tying business logic to specific I/O devices is a costly mistake — architecture should be agnostic to whether data comes from cards, terminals, files, or APIs.

Showing
Ad (728×90)

Why this matters

In the 1960s, programs were written directly for punched cards. When magnetic tape appeared, code had to be completely rewritten — not because the business logic changed, but because the input device changed. Device-independent operating systems solved this by abstracting I/O into a uniform interface: the same program ran on cards, tape, or disk without modification. The same lesson applies today.

HTTP, stdin, message queues, and file uploads are all delivery mechanisms — they are the modern equivalent of punched cards and tape. Your business logic should be as indifferent to how data arrives as an OS is to which physical disk is installed. When payroll logic knows about sys.stdin, it's a program written for punched cards all over again.

The problem

A payroll function that reads from stdin and writes to a printer device — the business rule is buried inside device-specific code and cannot be reused or tested alone.

Bad

import sys

def run_payroll():
    for line in sys.stdin:          # tied to stdin
        parts = line.strip().split(",")
        employee_id = parts[0]
        hours, rate = float(parts[1]), float(parts[2])
        gross = hours * rate
        tax   = gross * 0.2
        net   = gross - tax
        with open("/dev/lp0", "w") as printer:  # tied to printer device
            printer.write(f"Employee {employee_id}: net={net:.2f}\n")

# Switching to REST API input: rewrite run_payroll entirely.
# Switching output from printer to PDF: rewrite run_payroll entirely.
# Testing: must pipe data through stdin and capture printer output.
import { Request, Response } from "express";

export function processPayroll(req: Request, res: Response): void {
  const employees = req.body.employees as Array<{ id: string; hours: number; rate: number }>;
  const results = employees.map(emp => {
    const gross = emp.hours * emp.rate;
    const tax   = gross * 0.2;
    const net   = gross - tax;
    return `<tr><td>${emp.id}</td><td>${net.toFixed(2)}</td></tr>`;
  });
  res.send(`<table>${results.join("")}</table>`);
}
// Business rule (tax calculation) is trapped inside an HTTP handler.
// Can't reuse for CLI payroll runs, PDF reports, or batch jobs.

The solution

Pure calculation function with no I/O — input source and output destination are wired at the boundary, not inside the business rule.

Good

from dataclasses import dataclass
from decimal import Decimal

@dataclass
class Employee:
    id: str
    hours: Decimal
    rate: Decimal

@dataclass
class PaySlip:
    employee_id: str
    gross: Decimal
    tax: Decimal
    net: Decimal

def calculate_payroll(employees: list[Employee]) -> list[PaySlip]:
    """Pure function — no stdin, no files, no HTTP. Device-agnostic."""
    slips = []
    for emp in employees:
        gross = emp.hours * emp.rate
        tax   = gross * Decimal("0.2")
        net   = gross - tax
        slips.append(PaySlip(emp.id, gross, tax, net))
    return slips

# Wired at the boundary — not inside the rule:
# stdin:    employees = parse_stdin_csv(sys.stdin)
# REST API: employees = parse_request_body(request.json)
# The same calculate_payroll() call in all cases.
export interface Employee { id: string; hours: number; rate: number; }
export interface PaySlip  { employeeId: string; gross: number; tax: number; net: number; }

export function calculatePayroll(employees: Employee[]): PaySlip[] {
  return employees.map(emp => {
    const gross = emp.hours * emp.rate;
    const tax   = gross * 0.2;
    const net   = gross - tax;
    return { employeeId: emp.id, gross, tax, net };
  });
}

// HTTP boundary wires it for REST:
// app.post("/payroll", (req, res) => {
//   const slips = calculatePayroll(req.body.employees);
//   res.json(slips);
// });

// CLI boundary wires it for stdin:
// const employees = parseStdinCsv(process.stdin);
// const slips     = calculatePayroll(employees);
// printToConsole(slips);

// calculatePayroll is testable with plain arrays — no I/O mocking needed.

Key takeaway

If your business logic knows what HTTP is, it's coupled to a delivery mechanism it shouldn't care about. HTTP, stdin, message queues, and files are all equivalent punched-card readers — wire them at the boundary and keep the business rule device-agnostic.

Done with this lesson?

Mark it complete to earn XP and track your progress.