Published on

Pragmatic Hexagonal/Oni​on Frontend: When Theory Slows Delivery

Pragmatic hexagonal frontend

I joined the platform with a strict hexagonal layout already in place, set up by a senior engineer with solid intentions. The design looked disciplined, yet the operational cost had been underestimated. Extra layers turned small changes into long tasks, duplication crept in at the seams, and TypeScript coverage was uneven, leaving holes right where certainty mattered.

The target never changed. We needed clear seams and the ability to move fast, not one or the other. With a few pragmatic adjustments, the architecture became a tool for speed instead of a brake.

Goals and SLOs for a frontend architecture

We aim for domain isolation, evolvability under pressure, tests that raise confidence, onboarding that does not drag, and steady bundle budgets. These outcomes must be visible and measured, or they degrade quietly.

Keep SLOs front and center. Feature lead time under a clear target, test feedback within minutes, a per‑change cap on bundle growth, and a bounded time to first byte for critical routes. When structure improves these numbers, it stays; when it hurts, we trim.

Hexagonal recap in one page

At the core sits the domain, free from frameworks and IO, with behavior modeled as pure use cases and invariants. Everything else orbits that core via dependency inversion and stable interfaces.

  • UI layer
    • React components and hooks
    • Consumes domain use cases and state projections
    • No data access or side effects beyond orchestration
  • Domain layer
    • Pure functions, invariants, and use cases
    • No knowledge of HTTP, storage, or rendering
    • Stable contracts expressed as types
  • Ports
    • Interfaces the domain calls to reach the outside world
    • Describe needs in domain language, not transport details
  • Adapters
    • Concrete implementations of ports (HTTP, storage, cache, messaging)
    • Translate protocols and DTOs to domain types
  • Data sources
    • External services and APIs used by adapters

Dependency direction points inward. UI depends on domain. Adapters depend on domain ports. The domain depends on nothing external.

A tiny example captures the flow:

  • a component invokes a domain use case to list articles;
  • the use case calls a port;
  • an HTTP adapter implements that port and fetches from the API.

Where it went wrong for us

The platform had layers by default, not by need:

  • ports multiplied
  • adapters filled with boilerplate
  • mappers repeated the same fields in many files
  • DTOs bled into the core because typing at the edges was partial.
    Abstractions felt leaky, so developers compensated with more ceremony, which made the feedback loop slower.

Delivery slowed down in visible ways. Small changes touched four or five places. Reviews focused on ritual rather than risk. You can feel the drag when moving code across layers becomes more common than shipping a feature slice.

The lesson was simple. Boundaries are valuable when they cut along cohesive responsibilities and reduce coupling. When they fragment one intention across many files, they add friction without buying safety.

Boundaries that actually pay off

Three seams are often enough for a platform of this shape: UI, Domain, and Data. Keep each seam thin and cohesive.

Let the domain own the business rules and expose small, intention‑revealing functions. Place the mapping from DTOs to domain types at the edges so the core never sees external shapes. Keep cross‑cutting concerns lightweight through composition rather than frameworks: logging, error classification, and telemetry belong to helpers that can be injected or mocked.

TypeScript strategy that keeps you safe

Model precise domain types and collapse external variance at boundaries. The core should only manipulate domain shapes with strong invariants.

Use strict null checks and discriminated unions for finite states. Prefer type guards and narrow functions over blunt assertions. Where useful, apply branded types for IDs and money amounts to prevent category errors. Keep types close to usage and expose a small, well‑named public surface from each module.

// Domain type and DTO with a mapper without semicolons
export type Article = {
  id: string
  title: string
  status: 'draft' | 'published'
}

export type ArticleDto = {
  id: string
  title: string
  state: 'D' | 'P'
}

export function mapArticle(dto: ArticleDto): Article {
  const status = dto.state === 'P' ? 'published' : 'draft'
  return { id: dto.id, title: dto.title, status }
}

export function isPublished(a: Article): a is Article {
  return a.status === 'published'
}

Measure the cost, not the ideology

Track cyclomatic complexity on hot files and the churn of modules touched by typical changes. Monitor onboarding time for a new engineer, and the duration and flakiness of the test suite. Add bundle and route budgets so growth is intentional.

When a boundary does not improve these signals, make it thinner or remove it. A single dashboard with these numbers allows the team to decide quickly and move on, which is the point of having structure in the first place.

The pragmatic hexagon

Two or three useful boundaries are usually enough. UI depends on domain. Domain depends on ports. Adapters implement ports. Stop at that level unless a concrete need pushes you further, such as performance isolation or a distinct compliance fence.

Say yes to a new layer only when it solves a specific issue like testability, performance, or security. Keep ports small and intention‑revealing so they are easy to mock and replace. Favor vertical slices that span UI→domain→adapter for a feature, because they keep cohesion high and make reviews about behavior rather than ceremony.

// A thin port and a minimal adapter without semicolons
export type ArticlesPort = {
  list: () => Promise<Article[]>
}

export function createHttpArticlesAdapter(fetchFn: typeof fetch, baseUrl: string): ArticlesPort {
  return {
    async list() {
      const res = await fetchFn(`${baseUrl}/articles`)
      const raw = await res.json() as ArticleDto[]
      return raw.map(mapArticle)
    }
  }
}

Acceptance check before adding structure

Before merging a new boundary, check three points that correlate with flow efficiency and clarity

  • Less duplication in code paths that change often
  • Lower cognitive load for the next engineer who reads the code
  • Simpler and faster tests on the domain surface

If only one box is ticked, consider a lighter change. Trade offs are normal. A richer adapter can protect the domain from protocol noise, while a leaner port can speed up tests and reduce coupling. Align choices with the SLOs you committed to.

Pitfalls to watch for

Mapping logic tends to spread unless it is anchored at the edge. Centralize it where DTOs enter the system and keep the rules in one place. Tests become brittle when mocking expands beyond seams; prefer exercising real ports with thin fakes or local adapters.

Business rules drift when they live in the UI. Keep them in the domain where types and invariants can protect them. Types also drift unless someone watches; a weekly pass to eliminate any and unknown pays for itself quickly.

Closing notes

Clean architecture is a tool, not a goal. Thin, focused boundaries create clarity and speed because they improve cohesion and control coupling. Cargo‑cult layers do the opposite by scattering one intention across many files.

Pick a few seams. Model the domain with care. Keep the edges small. Measure impact. Ship. Learn. Repeat.

EDIT — This article was written before the current wave of AI‑assisted coding. The principles remain valid and useful, yet a large part of the boilerplate work (adapters, mappers, scaffolding for contract tests) can now be accelerated with AI tools that can write the code for you. Keep humans in the loop for domain boundaries, acceptance criteria, and trade‑off decisions, because that is where judgment creates leverage.

Last updated
© 2025, Devpulsion.
Exposio 1.0.0#82a06ca | About