Layers and Boundaries
Organizing code into parts with clear responsibilities: layered architecture, hexagonal architecture with ports and adapters, clean architecture, and keeping dependencies pointing inward.
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:
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.
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:
- Entities: business objects and rules that would exist without any software (a tariff, a meter reading);
- Use cases: what this application does with them (record a reading, produce an annual statement);
- Interface adapters: controllers, presenters and gateways that convert between the use cases and the outside;
- 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) |