Hexagonal Architecture for a Presentation Service
A presentation service, the thin backend behind a UI fragment, rots into glue code. Ports-and-adapters keeps it maintainable by forcing the core to depend on nothing concrete.
A presentation service is the thin backend that sits behind a single UI fragment: it formats money and dates, resolves copy and i18n, shapes session state, and proxies one downstream domain service on the UI’s behalf. Teams treat it as a dumping ground for glue code, and that glue rots into a mud-ball, with money formatting tangled into HTTP handlers and the downstream client’s quirks leaking into business rules. The discipline that keeps this service maintainable is ports-and-adapters (hexagonal architecture), which reduces to one rule: force the domain core to depend on nothing concrete.
The Glue-Code Mud-Ball
The failure mode is predictable. A service that starts as a “thin proxy” quietly accretes responsibilities until no boundary survives:
- Formatting, i18n, session shaping, auth token handling, and downstream-client workarounds pile up with no separation between them.
- Money and date formatting calls get inlined into HTTP controllers, so the only way to test the formatting logic is to spin up an HTTP server.
- The downstream service’s wire format (field names, error shapes, pagination quirks) leaks into business decisions. Swapping or versioning that client means touching everything.
- A money or i18n library gets called from dozens of files, so replacing it becomes a project of its own.
- Observability is bolted on per-handler, inconsistently, instead of wired once at the edges.
Each of these is a dependency pointing the wrong way: the core logic depends on a concrete library, a concrete protocol, or a concrete wire format. Reverse those arrows and the mud-ball stops forming.
The Dependency Rule
The whole pattern is a single constraint on dependency direction. Cockburn’s original 2005 intent for hexagonal architecture was to “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.” Martin states the same constraint as the dependency rule: “source code dependencies can only point inwards. Nothing in an inner circle can know anything at all about something in an outer circle,” and specifically, “data formats used in an outer circle should not be used by an inner circle.”
In practice this means the domain core imports no HTTP library, no money library, and no i18n catalog. It declares the interfaces it needs (the ports), and concrete implementations (the adapters) live at the edges and depend on the core, never the reverse. The layering is not bureaucracy. It exists so the domain core can be exercised without a network, a clock, or a locale.
Hexagonal, onion, and clean architecture are a family, not rivals. Onion (Palermo, 2008) and clean (Martin, 2012) both build on the same dependency-inversion idea with different drawings. Treat the terms as describing one principle rather than competing for adoption.
The Layers
A presentation service maps cleanly onto the hexagon. Generalized, the layout looks like this:
domain/is the isolated core, holding the presentation rules. It has no knowledge of HTTP, money libraries, or i18n, and it owns the port interfaces it needs.gateways/holds outbound clients to the downstream domain service. This is the driven side.adapters/holds the replaceable edges: HTTP, money formatting, date, translation, and auth token credentials, each implementing a port.endpoints/(controllers) is the inbound HTTP edge. This is the driving side.- A small observability layer (metrics, tracing, structured logging) is wired at the edges, not in the core.
AWS Prescriptive Guidance frames the same structure vendor-neutrally: “the application communicates with external components over interfaces called ports, and uses adapters to translate the technical exchanges,” and it notes the domain-driven design fit, where “each application component represents a sub-domain in DDD.”
On the observability point, OpenTelemetry gives the vendor-neutral framing. Its three signals are traces, metrics, and logs, and instrumentation is the act of emitting them. For a presentation service, instrument the adapters: the HTTP edge and the downstream gateway are where requests cross a boundary and where latency and errors are observable. Keep the domain core signal-free, so its tests stay pure and its logic stays portable.
Adapters as Swappable Edges
The payoff is concrete. The core declares a port that depends on nothing:
// domain/ports.ts — owned by the core, depends on nothing concrete
export interface MoneyFormatter {
format(amountMinor: number, currency: string, locale: string): string;
}
An adapter implements it at the edge:
// adapters/money/intl-money-formatter.ts — one swappable implementation
import type { MoneyFormatter } from "../../domain/ports";
export class IntlMoneyFormatter implements MoneyFormatter {
format(amountMinor: number, currency: string, locale: string): string {
return new Intl.NumberFormat(locale, { style: "currency", currency })
.format(amountMinor / 100);
}
}
Swapping the money library, the downstream client, or the i18n backend now touches one adapter file. The domain core’s tests never change, because the core only ever saw the MoneyFormatter interface. Note the distinction between dependency injection and the dependency rule. Injection is the wiring mechanism that hands an adapter to the core at startup; the dependency rule is the directional constraint that makes the core compile without ever importing the adapter. Injection without the rule still leaves you coupled.
Testing the Isolated Core
This is the single most cited benefit in Cockburn’s original write-up: develop and test in isolation. Because the core only depends on its ports, you test it with in-memory fakes, with no HTTP server, no network, no system clock, and no real translation catalog.
// domain/__tests__/present-checkout.test.ts
import { presentCheckout } from "../present-checkout";
import type { MoneyFormatter, CheckoutGateway } from "../ports";
const fakeFormatter: MoneyFormatter = {
format: (m, c, l) => `${c} ${(m / 100).toFixed(2)} [${l}]`,
};
const fakeGateway: CheckoutGateway = {
async getCart() {
return { totalMinor: 12999, currency: "USD" };
},
};
test("shapes the cart total for the locale", async () => {
const view = await presentCheckout(
{ locale: "en-US" },
{ formatter: fakeFormatter, gateway: fakeGateway },
);
expect(view.total).toBe("USD 129.99 [en-US]");
});
The test runs in milliseconds and asserts on the shaped presentation output with zero I/O. When the downstream service changes its wire format, that change is absorbed in the gateway adapter, not here. When a designer changes how money should read in a locale, you assert the new string against a fake formatter without touching the core’s logic.
Presentation Service vs BFF
The most common error is conflating a presentation service with a Backend-for-Frontend. They answer different questions, and the distinction matters.
A BFF is defined by who consumes it. The pattern was developed at SoundCloud, where the term was coined by their web tech lead Nick Fisher. Sam Newman gives the canonical definition: “rather than have a general-purpose API backend, instead you have one backend per user experience,” and the BFF is “tightly coupled to a specific user experience” and “maintained by the same team as the user interface.” A BFF typically fans out to many downstream services, aggregating per UI. For the resilience implications of that fan-out, see the GraphQL BFF post.
A presentation service is defined by what it does: presentation logic over essentially one domain dependency, organized internally by hexagonal layering. It can be a BFF, but topology and internal structure are orthogonal axes:
- BFF answers a topology and ownership question: one backend per frontend, owned by the UI team, fanning out to many services.
- Hexagonal answers an internal-structure question: which way do dependencies point inside the service.
Calçado documents the historical drift that makes this confusion easy: SoundCloud’s first BFFs “still looked very much like the public API” before presentation concepts (the profile page treated as a server-side concept, for example) migrated into them. So a BFF can accrete presentation logic, which is exactly why the internal-structure question stays separate from the topology question. A ports-and-adapters layout does not make something a BFF, and a BFF is not required to be hexagonal inside. Name the thing a presentation service when it has one domain dependency, so you do not inherit fan-out resilience assumptions that do not apply.
A Position on Symmetry
There is a genuine tension worth naming. Fowler credits the hexagon for “using similarities between presentation layer and data source layer to create symmetric components made of a core surrounded by interfaces,” but he flags the same property as a drawback: it hides “the inherent asymmetry between a service provider and a service consumer that would better be represented as layers.”
For a presentation service the asymmetry is real. There is one inbound HTTP edge and several driven adapters. The practical position is to keep the port discipline (the core owns interfaces, dependencies point inward) while letting the driven side read as ordinary layers. You gain testability from the ports without pretending the inbound and outbound sides are mirror images.
When NOT To Do This
The structure has a real upfront cost, and the honest case against it comes from the same authoritative sources. Skip it in these cases:
- A truly trivial pass-through, with no formatting, no i18n, and no session shaping, does not need a hexagon. The ceremony costs more than the glue it replaces.
- Premature port-everything. Defining ports for things with exactly one implementation and no test seam adds indirection without payoff. Introduce a port when you have a real reason to swap, fake, or isolate.
- Short-lived or single-person work. A throwaway experiment or a prototype rarely repays the structure.
AWS Prescriptive Guidance states the pragmatist caveat directly: the adapter code “is justified only if the application component requires several input sources and output destinations… or when the inputs and output data store has to change over time. Otherwise, the adapter becomes another additional layer to maintain, which introduces maintenance overhead.” It also notes that “using ports and adapters adds another layer, which might result in latency.”
This is the maximalist-versus-pragmatist split. Node and TypeScript tutorials tend to present ports-and-adapters as the default good structure; the pragmatist reading is that the bet only pays off when the service lives long enough and accretes enough glue to repay the boundaries.
Common Pitfalls
- Formatting and i18n calls live in controllers. Move them behind a port the core owns.
- The downstream wire format leaks into the core. The gateway adapter should map wire shapes to domain types at the boundary.
- Ports get defined with one implementation and no test. Introduce ports at a real swap, fake, or isolate need, not speculatively.
- The service gets called a BFF and inherits fan-out resilience assumptions it does not have. Name it a presentation service with one domain dependency.
- Observability is sprinkled per-handler. Instrument the adapters and keep the core signal-free.
A lightweight guard helps here: a lint rule that forbids any HTTP, money, or i18n import inside domain/. If that rule passes, the dependency arrows are pointing the right way, and the core can still be tested with zero I/O.
Closing
For a long-lived presentation service, organize it as ports-and-adapters and let the domain core depend on nothing concrete. Wire protocol, format, vendor, and observability at the edges, where they can be swapped, faked, and instrumented without touching business logic. The boundary is honest: skip the structure for trivial pass-throughs and short-lived prototypes, where the indirection costs more than it returns. Lead with the discipline of inward-pointing dependencies, not with a catalog of patterns, and keep the BFF question separate, since it is about topology, not internal structure.
References
- Hexagonal Architecture — Alistair Cockburn (mirror) - The canonical statement of intent: develop and test in isolation from devices and databases.
- The Clean Architecture — Robert C. Martin - The dependency rule verbatim: source-code dependencies point only inward.
- Hexagonal architecture pattern — AWS Prescriptive Guidance - Vendor-neutral ports and adapters definition, DDD fit, and the pragmatist maintenance-overhead caveat.
- Hexagonal architecture (software) — Wikipedia - Lineage to onion and clean architecture, plus Fowler’s symmetry-versus-asymmetry criticism.
- Pattern: Backends For Frontends — Sam Newman - The canonical BFF definition: one backend per user experience.
- The Back-end for Front-end Pattern (BFF) — Phil Calçado - BFF origin at SoundCloud and how presentation logic migrated into BFFs over time.
- OpenTelemetry — Concepts - The three signals (traces, metrics, logs) behind wiring telemetry at the edges.
- OpenTelemetry — Instrumentation - What instrumentation means, supporting the edge-instrumentation framing.
- aws-lambda-domain-model-sample (GitHub) - A worked reference layout of the pattern for a concrete file structure.
- Hexagonal Architecture and Clean Architecture (with examples) — DEV - A community Node and TypeScript treatment of ports as interfaces and adapters as classes.
- Future-Proof Your Code: Ports & Adapters — Alex Rusin - An accessible TypeScript-flavored walkthrough of the pattern.
Related posts
Bake a single PII branded type into your observability API signatures so TypeScript rejects sensitive fields at the call site, before any runtime redactor sees them.
Enterprise patterns for Model Context Protocol: tool composition, multi-agent orchestration, role-based access control, and production observability.
When to use service-based, domain-based, feature-based, or layer-based organization in AWS CDK projects, with decision frameworks and migration strategies.
A comprehensive introduction to Domain-Driven Design: core concepts, building blocks, strategic patterns, and when and how to apply DDD in practice.
AppSync subscriptions fire only on mutations. This explores bridging downstream BFF events into a NONE-data-source mutation with EventBridge and CDK.