Why boundaries matter

Inside one application, a boundary is a line that code is not supposed to cross except in agreed ways: through a function, an interface or an API. Boundaries apply separation of concerns at the scale of a whole application. They let the billing rules be tested without a database, let the database change without rewriting the billing rules, and let a new developer find where something belongs.

Boundaries erode quietly. Kestrel's customer portal shows invoices by querying KBS's tables on db01 directly, because it was quicker than asking the billing team for an API. It works, until Hamid Rahimi renames a column for KBS and the portal's invoice page breaks in production. The portal crossed a boundary that nobody had drawn, so nobody noticed. The rest of this page is about drawing those lines inside one application; between applications and services, the same ideas return in API Design Between Services.

Layered architecture

A layered architecture (also n-tier or n-layer) splits an application into horizontal layers, each with one kind of responsibility, where each layer may only use layers below it. The classic three, as popularized by Martin Fowler's Patterns of Enterprise Application Architecture (2002), are presentation, domain or business logic, and data access. KBS is built that way:

The three layers of KBS. Presentation, with web pages and the HTTP API, uses the application layer with invoicing, tariffs and payments, which uses the data access layer with repositories and SQL, which uses the database server db01. A dashed arrow from presentation straight to data access is a layer skip: allowed in a relaxed layered architecture, not allowed in a strict one. Presentation web pages, HTTP API Application / business logic invoicing, tariffs, payments Data access repositories, SQL db01 layer skip relaxed: allowed strict: not allowed
Arrows point from the layer that uses to the layer that is used. Nothing points up.

Two variants exist:

  • Strict (closed) layering: each layer may only use the layer directly below it. Changes stay contained, but simple read-only screens must pass through a business layer that adds nothing.
  • Relaxed (open) layering: a layer may use any layer below it. Less ceremony, but the business layer can be bypassed, and rules that should be enforced there are skipped.

Whichever variant a team chooses, a dependency pointing up is always a mistake: once the data access layer imports something from the presentation layer, the layers can no longer be changed or tested separately. A small script can check the rule against the real imports:

LAYERS = ["presentation", "application", "data access"]   # top to bottom

layer_of = {
    "kbs.web.invoice_api": "presentation",
    "kbs.web.statement_page": "presentation",
    "kbs.invoicing.service": "application",
    "kbs.invoicing.repository": "data access",
}

imports = {
    "kbs.web.invoice_api": ["kbs.invoicing.service"],
    "kbs.web.statement_page": ["kbs.invoicing.repository"],  # "just a quick query"
    "kbs.invoicing.service": ["kbs.invoicing.repository"],
    "kbs.invoicing.repository": ["kbs.web.invoice_api"],   # formats an error for HTTP
}


def problems(strict):
    for module, targets in imports.items():
        here = LAYERS.index(layer_of[module])
        for target in targets:
            there = LAYERS.index(layer_of[target])
            if there < here:
                yield f"UPWARD  {module} -> {target}"
            elif strict and there > here + 1:
                yield f"SKIPS   {module} -> {target}"


print("relaxed:", *problems(strict=False), sep="\n  ")
print("strict:", *problems(strict=True), sep="\n  ")
relaxed:
  UPWARD  kbs.invoicing.repository -> kbs.web.invoice_api
strict:
  SKIPS   kbs.web.statement_page -> kbs.invoicing.repository
  UPWARD  kbs.invoicing.repository -> kbs.web.invoice_api

Checks like this belong in the build pipeline, so a boundary violation fails a pull request instead of surviving until someone notices; Evolving Architecture calls them fitness functions.

Layered architecture is a sensible default for business applications: everyone understands it, and frameworks are built around it. Its weak spot is the bottom. The business layer depends on the data access layer, so the most important code sits on top of the most technical code, and testing a tariff rule means setting up a database. The next two styles fix exactly that.

Hexagonal architecture

Alistair Cockburn described hexagonal architecture in 2005, also under the more descriptive name ports and adapters. Its stated intent: "Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases." In short: the application's core (domain model and use cases) sits in the middle and knows nothing about the technology around it. HTTP, databases, message brokers, files and tests are all on the outside and talk to the core only through ports.

The hexagon shape has no meaning of its own; there are not six of anything. It is drawn that way to get away from the top-to-bottom picture of layers, and to leave room around the edge for ports.

Ports and adapters

A port is an interface that belongs to the application's core and describes one way of talking to it, in the core's own terms. An adapter is code outside the core that connects one specific technology to a port: it translates an HTTP request into a call on the core, or implements a storage port with SQL. Ports come in two kinds:

  • Driving (primary) ports are how the outside world uses the application: "record a reading". The HTTP endpoint, a batch import and the tests are driving adapters.
  • Driven (secondary) ports are what the application needs from the outside world: "store a reading", "publish an event". The PostgreSQL code and a fake for tests are driven adapters.
meter-api as a hexagon. In the middle is the meter-api core. On the left, three driving adapters, the HTTP endpoint, the CSV import and the tests, connect to the driving port RecordReading. On the right, two driven ports on the hexagon's edge: ReadingStore, implemented by a PostgreSQL adapter for db01 and an in-memory store, and EventPublisher, implemented by a broker publisher and a fake for tests. All arrows point toward the hexagon. driving adapters driven adapters meter-api core HTTP endpoint CSV import Tests RecordReading ReadingStore EventPublisher PostgreSQL · db01 In-memory store Broker publisher Fake (tests) small squares are ports; arrows are source-code dependencies
meter-api as ports and adapters. Driving adapters call into the core; driven adapters are called by it, but they still depend on the core's interfaces, not the other way round.

Here is meter-api's core with one driven port, ReadingStore, and two adapters for it: an in-memory one for tests and one using SQLite, standing in for PostgreSQL on db01. The use case runs unchanged on both:

import sqlite3
from dataclasses import dataclass
from typing import Protocol


# ---- inside the hexagon: domain and use case, no database code ----
@dataclass(frozen=True)
class Reading:
    meter: str
    at: str          # start of the 15-minute interval, UTC
    wh: int


class ReadingStore(Protocol):                  # driven port, defined by the core
    def add(self, reading: Reading) -> bool: ...
    def total_wh(self, meter: str) -> int: ...


class RecordReading:                           # the use case
    def __init__(self, store: ReadingStore):
        self.store = store

    def __call__(self, meter, at, wh):
        if wh < 0:
            return "rejected: negative usage"
        added = self.store.add(Reading(meter, at, wh))
        return "stored" if added else "duplicate ignored"


# ---- outside: two adapters for the same port ----
class InMemoryStore:
    def __init__(self):
        self.readings = {}

    def add(self, r):
        if (r.meter, r.at) in self.readings:
            return False
        self.readings[(r.meter, r.at)] = r
        return True

    def total_wh(self, meter):
        return sum(r.wh for r in self.readings.values() if r.meter == meter)


class SqliteStore:
    def __init__(self, db):
        self.db = db
        db.execute("CREATE TABLE reading (meter TEXT, at TEXT, wh INTEGER,"
                   " PRIMARY KEY (meter, at))")

    def add(self, r):
        cursor = self.db.execute("INSERT OR IGNORE INTO reading VALUES (?, ?, ?)",
                                 (r.meter, r.at, r.wh))
        return cursor.rowcount == 1

    def total_wh(self, meter):
        row = self.db.execute("SELECT COALESCE(SUM(wh), 0) FROM reading"
                              " WHERE meter = ?", (meter,))
        return row.fetchone()[0]


# ---- composition root: the only place that knows which adapter is used ----
for store in (InMemoryStore(), SqliteStore(sqlite3.connect(":memory:"))):
    record = RecordReading(store)
    results = [record("E-1001", "2026-10-06T17:00Z", 212),
               record("E-1001", "2026-10-06T17:15Z", 198),
               record("E-1001", "2026-10-06T17:15Z", 198),
               record("E-1001", "2026-10-06T17:30Z", -5)]
    print(f"{type(store).__name__:13}", results, store.total_wh("E-1001"), "Wh")
InMemoryStore ['stored', 'stored', 'duplicate ignored', 'rejected: negative usage'] 410 Wh
SqliteStore   ['stored', 'stored', 'duplicate ignored', 'rejected: negative usage'] 410 Wh

The validation rule (no negative usage) and the duplicate rule live in the core and are tested once. Swapping SQLite for PostgreSQL, or adding an object-relational mapper, touches only an adapter. In a real application the core and the adapters would be separate modules, and the core's module would not import sqlite3 at all.

The other side of the hexagon works the same way. The HTTP endpoint and Joost Bakker's monthly correction file are two driving adapters for the same use case: each translates its own format and calls the core. Neither contains a business rule.

import csv
import io
import json


class RecordReading:                         # the use case, as in the example above
    def __init__(self, store):
        self.store = store

    def __call__(self, meter, at, wh):
        if wh < 0:
            return "rejected: negative usage"
        if (meter, at) in self.store:
            return "duplicate ignored"
        self.store[(meter, at)] = wh
        return "stored"


# Driving adapter 1: what the HTTP endpoint does with a request body
def handle_post(record, body):
    data = json.loads(body)
    result = record(data["meter"], data["at"], int(data["wh"]))
    return (201 if result == "stored" else 200), result


# Driving adapter 2: Joost's monthly correction file
def import_corrections(record, text):
    rows = csv.DictReader(io.StringIO(text), delimiter=";")
    return [record(row["meter"], row["at"], int(row["wh"])) for row in rows]


record = RecordReading(store={})
print(handle_post(record, '{"meter": "E-1001", "at": "2026-10-06T17:00Z", "wh": 212}'))
print(import_corrections(record, "meter;at;wh\n"
                                 "E-1001;2026-10-06T17:00Z;212\n"
                                 "E-1002;2026-10-06T17:00Z;187\n"))
(201, 'stored')
['duplicate ignored', 'stored']

Because the import goes through the same use case, a correction for a reading that already arrived is recognized as a duplicate, with no second copy of the rule in the import script. The tests are a third driving adapter, which is why hexagonal code is easy to test: most tests call the core directly, and only a few integration tests need the real database.

Clean and onion architecture

Two later styles draw the same idea as concentric rings. Jeffrey Palermo's onion architecture (2008) states its rule as "All code can depend on layers more central, but code cannot depend on layers further out from the core." Robert C. Martin's clean architecture (blog post 2012, book 2017) combines hexagonal, onion and similar ideas into four rings, from the inside out:

  1. Entities: business objects and rules that would exist without any software (a tariff, a meter reading);
  2. Use cases: what this application does with them (record a reading, produce an annual statement);
  3. Interface adapters: controllers, presenters and gateways that convert between the use cases and the outside;
  4. Frameworks and drivers: the web framework, the database, the message broker.

The ring names differ between authors, but the styles agree far more than they differ. Choosing between them matters less than following the rule they share.

The dependency rule

The dependency rule says that source code dependencies may only point inward, toward the business rules: nothing in an inner ring may name anything in an outer ring, not a class, not a function, not a database table. In Martin's words: "source code dependencies can only point inwards."

The obvious question is how the core can store a reading in PostgreSQL if it may not depend on PostgreSQL. The answer is the dependency inversion principle. At run time, the call goes outward: the use case calls store.add(), which runs SQL. In the source code, the dependency points inward: the core defines the ReadingStore port, and the SQL adapter imports and implements it. The control flow and the dependency arrow point in opposite directions, and that is the whole trick.

Dependency injection

Dependency injection (DI) means giving a piece of code the objects it works with, usually through its constructor or as parameters, instead of letting it create or look them up itself. RecordReading(store) in the examples above is dependency injection: the use case does not know or decide which store it gets. That is what makes the same code run against SQLite in one line and a dictionary in the next.

The name became common through an article by Martin Fowler on inversion of control containers. Large frameworks automate it with a container that builds objects and fills in their constructor parameters: ASP.NET Core has one built in, and Spring does it for Java. A container is a convenience, not a requirement; the Java and C# testing pages show the plain version on testable Java code and testable C# code.

The composition root

If no part creates its own collaborators, something must. The composition root is the one place, as close as possible to the program's entry point, where the concrete adapters are chosen and the whole object graph is wired together. Everything else receives what it needs. Keeping the wiring in one place means that switching from the in-memory store to PostgreSQL, or adding the broker, is a change to a dozen lines in main, not a search through the code base.

TypeScript shows the shape well, because its interfaces and implements clauses make the ports and adapters explicit and the compiler checks every connection:

// Ports: interfaces owned by the core
interface ReadingStore {
  add(meter: string, at: string, wh: number): boolean;
}

interface EventPublisher {
  publish(event: string): void;
}

// Use case: gets its collaborators through the constructor, never creates them
class RecordReading {
  constructor(
    private readonly store: ReadingStore,
    private readonly events: EventPublisher,
  ) {}

  run(meter: string, at: string, wh: number): string {
    if (!this.store.add(meter, at, wh)) return "duplicate ignored";
    this.events.publish(`ReadingReceived ${meter} ${at} ${wh} Wh`);
    return "stored";
  }
}

// Adapters: details that implement the ports
class InMemoryStore implements ReadingStore {
  private readonly readings = new Map<string, number>();

  add(meter: string, at: string, wh: number): boolean {
    const key = `${meter}@${at}`;
    if (this.readings.has(key)) return false;
    this.readings.set(key, wh);
    return true;
  }
}

class ConsolePublisher implements EventPublisher {
  publish(event: string): void {
    console.log(`published: ${event}`);
  }
}

// Composition root: the one place that chooses the adapters and wires them up
function main(): void {
  const recordReading = new RecordReading(new InMemoryStore(), new ConsolePublisher());
  console.log(recordReading.run("E-1001", "2026-10-06T17:00Z", 212));
  console.log(recordReading.run("E-1001", "2026-10-06T17:00Z", 212));
}

main();
published: ReadingReceived E-1001 2026-10-06T17:00Z 212 Wh
stored
duplicate ignored

RecordReading never says new about anything. In production, main would pass a PostgreSQL store and, if the message broker proposed in ADR-0007 is accepted, a broker publisher instead; in a test, a fake that records what was published. The interfaces are checked structurally, so the implements clauses are documentation the compiler verifies rather than a requirement; see implementing interfaces.

Choosing how far to go

Every boundary is code: an interface, an adapter, a mapping from a database row to a domain object and back. That cost pays off when the business logic is valuable and long-lived, and is wasted when there is hardly any logic to protect.

Situation Sensible structure
A script, a one-off report, a small internal tool No layers; keep pure calculations in functions and the I/O at the edges
A data entry application that mostly reads and writes forms Simple layers; relaxed layering is fine
Rich business rules that must outlive frameworks (KBS tariffs, meter-api validation) Hexagonal or clean: a core without technical dependencies, ports for storage and messaging
Several teams on one code base Boundaries checked by the build, and modules per business area (see Monolith, Modular Monolith and Microservices)