Skip to content

Hexagonal Architecture for a Presentation Service vs BFF

A presentation service is the thin backend behind a UI fragment. Ports-and-adapters keeps it from rotting into glue code by pointing dependencies inward.

Ayhan Sipahi Ayhan Sipahi

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. Money formatting gets tangled into HTTP handlers, and the downstream client’s quirks leak 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#

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.

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 core can then be exercised without a network, a clock, or a locale.

Hexagonal, onion, and clean architecture belong to the same family. Onion (Palermo, 2008) and clean (Martin, 2012) build on the same dependency-inversion idea with different drawings, so the three names describe one principle.

The File Layout#

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.

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.”

HTTP Controller (driving adapter)

Inbound Port

Domain Core (presentation rules)

Gateway Port

Money Port

i18n Port

Downstream Gateway

Money Formatter

Translation Adapter

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, while the domain core stays signal-free, keeping its tests pure and its logic portable.

Adapters as Swappable Edges#

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, and injection without that rule still leaves the core coupled to the adapter.

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. 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 a worked example of that fan-out across order, inventory, and payment services, see Triggering AppSync Subscriptions From Outside AppSync.

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, because 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 about which way dependencies point inside the service.

Presentation Service (structure choice)

UI Fragment

HTTP Edge

Domain Core

One Domain Service

BFF (topology choice)

Mobile UI

BFF

Service A

Service B

Service C

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, gaining testability from the ports without treating the inbound and outbound sides as mirror images.

What the Hexagon Costs#

The structure has a real upfront cost. 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 only when you have a concrete reason to replace the implementation, substitute it in tests, or keep it separate from the core.
  • 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.

No

Yes

No

Yes

No

Yes

Presentation logic over one domain dependency?

Plain proxy (skip the hexagon)

Long-lived?

Real swap, fake, or isolate need?

Light structure (defer ports)

Ports-and-adapters (recommended default)

Leaks Worth a Lint Rule#

Two leaks show up first: formatting and i18n calls that live in controllers, and downstream wire shapes that reach the core. Move the first behind a port the core owns; the second belongs in the gateway adapter, which maps wire shapes to domain types at the boundary. A lint rule that forbids any HTTP, money, or i18n import inside domain/ catches both. Two quieter ones are worth a review pass: ports defined with a single implementation and no test, and observability sprinkled per handler.

Where the Default Holds#

The default holds for a long-lived presentation service that does real formatting, i18n, and session work: keep protocol, format, vendor, and observability at the edges. Override it for a trivial pass-through or a throwaway prototype.

References#

Related posts