Back to Blog
Software

Hexagonal Architecture: The Complete TypeScript Guide to Code That Gets Easier to Change Over Time

Tired of 'Jenga' codebases where every new feature feels like pulling a block from a rickety tower? Discover the 2005 pattern that flips the dependency arrow to make your TypeScript apps truly modifiable.

2026-01-26
12 min read

You add a feature. Three files break.

You write a test. It needs a real database to run, so you skip it. "I'll come back to it later." You won't.

You want to swap out your email provider. But the SDK is imported in six different service files, tangled into business logic like Christmas lights in January. So you don't swap it. You live with the one you hate.

If you're a developer who ships fast — maybe building your own product, maybe contracting, maybe leading a small team — you've felt this. Your app works. It runs. Users pay for it. But changing it? That's where the pain lives.

I'll be honest with you: for a long time, I thought the problem was my code. Sloppy logic. Not enough tests. Not enough discipline.

It wasn't any of those things.

The problem wasn't my code. It was how my code was organized.

There's a name for the fix. It's been around since 2005. Netflix uses it to run 3,000 tests in 100 seconds. And in this guide, I'm going to show you exactly how it works — with real TypeScript code you can steal.


The 5 Strategic Taxes on Your Codebase (and the Hexagonal Cure)

Before we talk about the fix, let's look at the costs. Most developers structure apps the same way: Controller calls Service, Service calls Repository, Repository talks to the database. It’s the "Layered Waterfall."

It works beautifully for MVPs. But as the app grows, you start paying "taxes" on every new feature. Here is how those pains evolve—and exactly how Hexagonal Architecture (Ports and Adapters) stops the bleeding.

1. The "Domain Leak" Tax

  • The Symptom: Your service file starts returning HTTP status code 404 or formatting JSON response objects. Suddenly, your "business logic" knows it lives inside a REST API. Alternatively, your service imports prisma directly, meaning your high-level business rules are fused to lower-level Postgres details.
  • The Hexagonal Cure: Strict Isolation. Your business logic sits in a "Core" that imports nothing from the outside. It defines the rules of your world (e.g., "A task can't be completed twice") in pure TypeScript, completely unaware of whether it's being called by an API, a CLI, or a test.

2. The Infrastructure Lock-in Tax

  • The Symptom: You want to swap your email provider (SendGrid to Resend) or move from Postgres to MongoDB, but the SDKs are "load-bearing walls." Swapping one means a full rewrite because the provider's logic is intertwined with your app's guts. You’re not using Stripe; Stripe is using you.
  • The Hexagonal Cure: Dependency Inversion. The core doesn't depend on the database; the database depends on the core. By using Ports (interfaces), the core says: "I need someone who can save a Task." It doesn't care how. You can swap a Postgres adapter for a Mongo adapter by changing one line of code in your setup, with zero changes to your business logic.

3. The Testing Bog Tax

  • The Symptom: Testing a "simple" business rule requires a real database or complex Prisma mocking. Tests get slow (seconds instead of milliseconds) and brittle (a schema migration breaks 50 unrelated logic tests). Developers stop running them. The test suite becomes decoration.
  • The Hexagonal Cure: In-Memory Adapters. Because your core interacts with a Port (interface), you can plug in a simple InMemoryRepository (a JS Map) for your tests. You can run 3,000 unit tests in under 2 seconds on a single process. No Docker, no DB, no waiting.

4. The Delivery Deadlock Tax

  • The Symptom: Adding a new way to interact with your app—like a CLI tool, a GraphQL API, or a Cron job—means duplicating your service logic or hackily importing Express-specific code into non-web contexts. Logic drifts, and maintenance becomes a nightmare.
  • The Hexagonal Cure: Pluggable Driving Adapters. In a hexagon, the API is just one of many "adapters" that plug into the core. Adding GraphQL is as simple as writing a new adapter that calls the same Use Case. The core doesn't even know it's being called by something new.

5. The Team Friction Tax

  • The Symptom: Two developers work on different features and constantly hit merge conflicts in the same monolithic service file. "Don’t touch that file" becomes a team mantra. Velocity drops because everyone is afraid of the "ripple effect."
  • The Hexagonal Cure: Clear Ownership and Parallelism. By separating concerns into Core, Ports, and Adapters, the boundaries are physical, not just theoretical. One dev can work on the new UI adapter while another refactors the database adapter, and neither touches the core business logic.

One developer noticed this pattern in real projects back in 2005. His insight flipped the way we think about structure: what if the app didn't know what database it was using? What if it didn't care how it was being triggered?

The 2005 Insight: Building from the Inside Out

In 2005, Alistair Cockburn published an observation that flipped software design on its head:

"The entanglement between the business logic and external entities is the root of all architectural evil."

His solution? Stop thinking in layers (Top-to-Bottom) and start thinking in boundaries (Inside vs. Outside).

The Three Pieces of the Puzzle

  1. The Core (The Inside): Your business rules. This is pure TypeScript. It imports nothing. It knows nothing about the web or databases.
  2. The Ports (The Border): These are Interfaces. They define contracts. The core says: "I need someone who can save a task." It doesn't define how.
  3. The Adapters (The Outside): These are Classes that implement the ports. This is where Prisma, Express, and SendGrid live.

The "Aha!" Moment: Dependency Inversion

In a traditional app, the Core depends on the Database. If the DB changes, the core might break.

In Hexagonal, the Database depends on the Core. The core defines the interface, and the database adapter must conform to it. The dependency arrow points inward, always. Your business logic becomes the "source of truth," and infrastructure becomes a plug-in.


Enough theory. Let me show you what this looks like in real code — the same app, refactored.


Real Code: Layered vs. Hexagonal

Let's look at a task management API. It has three jobs: Create a task, get a task, and mark it complete.

The "Before": The Layered Trap

This is the version where everything is "bolted" together. Watch how the Service (business logic) is forced to know about Prisma (infrastructure).

This is the layered approach. If you've built a Node.js app, you've probably written something like this.

Folder structure:

src/
├── controllers/
│   └── taskController.ts
├── services/
│   └── taskService.ts
├── repositories/
│   └── taskRepository.ts
└── models/
    └── task.ts

The model:

typescript
// models/task.ts
export interface Task {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
}

The repository (directly coupled to Prisma):

typescript
// repositories/taskRepository.ts
import { prisma } from '../lib/prisma';

export async function saveTask(title: string) {
  return prisma.task.create({
    data: { title, completed: false }
  });
}

export async function findTaskById(id: string) {
  return prisma.task.findUnique({ where: { id } });
}

export async function markTaskComplete(id: string) {
  return prisma.task.update({
    where: { id },
    data: { completed: true }
  });
}

The service (business logic mixed with infrastructure):

typescript
// services/taskService.ts
import { findTaskById, markTaskComplete, saveTask } from '../repositories/taskRepository';

export async function createTask(title: string) {
  if (!title || title.trim().length === 0) {
    throw new Error('Title is required');
  }
  return saveTask(title);
}

export async function getTask(id: string) {
  const task = await findTaskById(id);
  if (!task) throw new Error('Task not found');
  return task;
}

export async function completeTask(id: string) {
  const task = await findTaskById(id);
  if (!task) throw new Error('Task not found');
  if (task.completed) throw new Error('Task already completed');
  return markTaskComplete(id);
}

The controller:

typescript
// controllers/taskController.ts
import { Router } from 'express';
import { createTask, getTask, completeTask } from '../services/taskService';

const router = Router();

router.post('/tasks', async (req, res) => {
  try {
    const task = await createTask(req.body.title);
    res.status(201).json(task);
  } catch (e: any) {
    res.status(400).json({ error: e.message });
  }
});

router.get('/tasks/:id', async (req, res) => {
  try {
    const task = await getTask(req.params.id);
    res.json(task);
  } catch (e: any) {
    res.status(404).json({ error: e.message });
  }
});

router.patch('/tasks/:id/complete', async (req, res) => {
  try {
    const task = await completeTask(req.params.id);
    res.json(task);
  } catch (e: any) {
    res.status(400).json({ error: e.message });
  }
});

export default router;

This looks clean. It works. Your app ships.

But look closer.

The service file — the one that's supposed to contain business logic — transitively depends on Prisma through the repository. You can't test completeTask without a running database. You can't swap Postgres for MongoDB without rewriting the repository and retesting every service that imports it.

The boundaries exist on paper. But nothing enforces them.

The "After": The Hexagonal Escape

Now, let's refactor. Notice the new directory structure: the core is now a protected vault. Nothing inside core is allowed to import from adapters.

src/
├── core/
│   ├── domain/
│   │   └── task.ts
│   ├── ports/
│   │   └── taskRepository.ts
│   └── usecases/
│       └── taskUseCases.ts
└── adapters/
    ├── driving/
    │   └── rest/
    │       └── taskRouter.ts
    └── driven/
        └── persistence/
            ├── prismaTaskRepository.ts
            └── inMemoryTaskRepository.ts

1. The Core (The Protecting Vault)

Notice in the Use Cases below: No imports from Prisma. Only the Repository Interface.

The domain (same as before):

typescript
// core/domain/task.ts
export interface Task {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
}

The port (an interface — the contract):

typescript
// core/ports/taskRepository.ts
import { Task } from '../domain/task';

export interface TaskRepository {
  save(title: string): Promise<Task>;
  findById(id: string): Promise<Task | null>;
  update(task: Task): Promise<Task>;
}

This lives inside the core. The core defines what it needs. It doesn't know who fulfills it.

The use cases (pure business logic — zero infrastructure):

typescript
// core/usecases/taskUseCases.ts
import { TaskRepository } from '../ports/taskRepository';

export class TaskUseCases {
  constructor(private readonly taskRepo: TaskRepository) {}

  async createTask(title: string) {
    if (!title || title.trim().length === 0) {
      throw new Error('Title is required');
    }
    return this.taskRepo.save(title);
  }

  async getTask(id: string) {
    const task = await this.taskRepo.findById(id);
    if (!task) throw new Error('Task not found');
    return task;
  }

  async completeTask(id: string) {
    const task = await this.taskRepo.findById(id);
    if (!task) throw new Error('Task not found');
    if (task.completed) throw new Error('Task already completed');
    return this.taskRepo.update({ ...task, completed: true });
  }
}

Look at the imports. TaskUseCases imports TaskRepository — the interface. Not Prisma. Not any database client. Just the contract.

The business rule is identical: "A task can only be completed if it's not already done." But now that rule lives in a place that has absolutely no idea what database you're using.

2. The Adapters (The External Plug-ins)

This is where the 'real world' lives. The REST adapter pushes data in; the Prisma adapter pushes data out.

typescript
// adapters/driven/persistence/prismaTaskRepository.ts
import { prisma } from '../../../lib/prisma';
import { Task } from '../../../core/domain/task';
import { TaskRepository } from '../../../core/ports/taskRepository';

export class PrismaTaskRepository implements TaskRepository {
  async save(title: string): Promise<Task> {
    return prisma.task.create({
      data: { title, completed: false }
    });
  }

  async findById(id: string): Promise<Task | null> {
    return prisma.task.findUnique({ where: { id } });
  }

  async update(task: Task): Promise<Task> {
    return prisma.task.update({
      where: { id: task.id },
      data: { completed: task.completed }
    });
  }
}

This class implements TaskRepository. It fulfills the contract. Prisma lives here — and only here.

The driving adapter (Express router — almost unchanged):

typescript
// adapters/driving/rest/taskRouter.ts
import { Router } from 'express';
import { TaskUseCases } from '../../../core/usecases/taskUseCases';

export function createTaskRouter(taskUseCases: TaskUseCases) {
  const router = Router();

  router.post('/tasks', async (req, res) => {
    try {
      const task = await taskUseCases.createTask(req.body.title);
      res.status(201).json(task);
    } catch (e: any) {
      res.status(400).json({ error: e.message });
    }
  });

  router.get('/tasks/:id', async (req, res) => {
    try {
      const task = await taskUseCases.getTask(req.params.id);
      res.json(task);
    } catch (e: any) {
      res.status(404).json({ error: e.message });
    }
  });

  router.patch('/tasks/:id/complete', async (req, res) => {
    try {
      const task = await taskUseCases.completeTask(req.params.id);
      res.json(task);
    } catch (e: any) {
      res.status(400).json({ error: e.message });
    }
  });

  return router;
}

Look at the routes. Same endpoints. Same status codes. Same response shapes. The REST API didn't change. Not one endpoint, not one status code. The only thing that changed is how the insides are wired.

3. The Composition Root (The Wiring)

This is the ONLY place where we choose our database. Changing from Postgres to Mongo happens in this file, and nowhere else.

typescript
// main.ts
import express from 'express';
import { PrismaTaskRepository } from './adapters/driven/persistence/prismaTaskRepository';
import { TaskUseCases } from './core/usecases/taskUseCases';
import { createTaskRouter } from './adapters/driving/rest/taskRouter';

const app = express();
app.use(express.json());

// This is the ONLY place where concrete implementations are chosen
const taskRepo = new PrismaTaskRepository();
const taskUseCases = new TaskUseCases(taskRepo);
const taskRouter = createTaskRouter(taskUseCases);

app.use(taskRouter);

app.listen(3000, () => console.log('Running on port 3000'));

One file — main.ts — is the only place in the entire application that knows you're using Prisma. Swap PrismaTaskRepository for MongoTaskRepository and you're done. One line. One file. Zero changes to business logic.

What Happens When You Test Without a Database

Here's where the payoff gets real. Remember the in-memory adapter in the folder structure? Let's build it:

typescript
// adapters/driven/persistence/inMemoryTaskRepository.ts
import { Task } from '../../../core/domain/task';
import { TaskRepository } from '../../../core/ports/taskRepository';
import { randomUUID } from 'crypto';

export class InMemoryTaskRepository implements TaskRepository {
  private tasks: Map<string, Task> = new Map();

  async save(title: string): Promise<Task> {
    const task: Task = {
      id: randomUUID(),
      title,
      completed: false,
      createdAt: new Date()
    };
    this.tasks.set(task.id, task);
    return task;
  }

  async findById(id: string): Promise<Task | null> {
    return this.tasks.get(id) ?? null;
  }

  async update(task: Task): Promise<Task> {
    this.tasks.set(task.id, task);
    return task;
  }
}

Now your tests:

typescript
// tests/completeTask.test.ts
import { TaskUseCases } from '../core/usecases/taskUseCases';
import { InMemoryTaskRepository } from '../adapters/driven/persistence/inMemoryTaskRepository';

describe('completeTask', () => {
  let useCases: TaskUseCases;

  beforeEach(() => {
    useCases = new TaskUseCases(new InMemoryTaskRepository());
  });

  it('marks an incomplete task as complete', async () => {
    const task = await useCases.createTask('Buy groceries');
    const completed = await useCases.completeTask(task.id);
    expect(completed.completed).toBe(true);
  });

  it('throws if task is already completed', async () => {
    const task = await useCases.createTask('Buy groceries');
    await useCases.completeTask(task.id);
    await expect(useCases.completeTask(task.id))
      .rejects.toThrow('Task already completed');
  });

  it('throws if task does not exist', async () => {
    await expect(useCases.completeTask('nonexistent'))
      .rejects.toThrow('Task not found');
  });
});

No database. No Docker. No test containers. No Prisma mock hacking. Just pure business logic, tested against a simple in-memory implementation of the same interface.

These tests run in milliseconds.

This isn't theoretical. Netflix reported that a test suite built on this exact pattern runs approximately 3,000 specifications in 100 seconds on a single process. Most of those are in-memory unit tests of business logic — no network, no database, no waiting.

The "So What?" Bridge

Now imagine you need to swap Postgres for MongoDB. In the "before" — you rewrite every repository, retest every service that imports it, and pray nothing breaks in the controllers.

In the "after" — you write one new class: MongoTaskRepository implements TaskRepository. You change one line in main.ts. You're done.

Imagine you want to add a GraphQL API alongside REST. In the "before" — you duplicate your service logic into a new set of resolvers. In the "after" — you write one new driving adapter that calls the same TaskUseCases. The business rules don't even know GraphQL exists.

That's not a theoretical benefit. That's a Thursday afternoon.


The Honest Part: When Hexagonal Architecture Will Hurt You

I'd be a lousy coach if I only showed you the highlight reel. This pattern has real costs, and pretending otherwise would make me the kind of writer I don't want to read.

When It's Worth It ✅

  • Your app has real business logic — not just CRUD pass-through.
  • You expect to swap infrastructure over time (databases, providers, APIs).
  • You want isolated, fast, deterministic tests.
  • Your team has more than one developer and needs clear boundaries.
  • You're building something that needs to last years, not weeks.
  • You're integrating with multiple external services that might change.

When It's NOT Worth It ❌

  • Simple CRUD microservice with minimal business logic. Five endpoints that just pass data from HTTP to the database and back.
  • Prototype, hackathon, or throwaway code. You're optimizing for speed, not longevity.
  • Your team has never used the pattern and has no time to learn. Badly implemented Hexagonal is worse than well-implemented layered.
  • Very small apps where you can hold the whole thing in your head.

If your entire app is request → validate → save → respond, you don't need a hexagon. You need a framework.

6 Costs You Should Know Before Refactoring

1. More boilerplate upfront. Ports, adapters, interfaces, constructor injection. For a small app, this feels like over-engineering — because for a small app, it is over-engineering.

2. Steeper learning curve. Concepts like Dependency Inversion and interfaces are unfamiliar to many JavaScript developers. The team needs time to internalize them. That's a real cost.

3. More files, more indirection. Following the code from HTTP request → adapter → port → use case → port → adapter → database is more hops than Controller → Service → Repository. Your IDE's "Go to Definition" gets a workout.

4. Mapping overhead. Translating between DTOs, domain objects, and persistence models adds code. The mapping strategy itself is a design decision that requires thought.

5. Requires architectural discipline. Without an experienced developer to enforce boundaries, teams can misapply the pattern and end up with worse code than if they'd used simple layers. The hexagon doesn't help if nobody respects its walls.

6. Overkill for CRUD apps. If your app has no meaningful business logic — if it's purely a data shuttle between HTTP and a database — Hexagonal adds complexity with zero payoff.

The Nuance

Hexagonal Architecture is a scalpel, not a sledgehammer. It shines when the business logic is complex enough to deserve protection from infrastructure churn. For a weekend project? Use Express and Prisma and move on.

The question isn't "should I always use Hexagonal Architecture?" The question is: "Is my business logic complex and valuable enough to protect?" If the answer is yes — and you felt the pain in those 16 points above — then the answer is clear.

A Quick Note on Neighbors

You might hear people mention Clean Architecture or Onion Architecture in the same breath. They're related, but different:

  • Clean Architecture (Robert C. Martin) is more prescriptive internally — it defines distinct rings for entities, use cases, and interface adapters. Hexagonal is simpler. It only cares about one boundary: inside vs. outside.

  • Onion Architecture shares the same core idea (dependencies point inward) but emphasizes concentric layers. Hexagonal emphasizes ports — the pluggable connection points.

All three agree on the fundamental insight: the dependency arrow must point inward. They just differ on how precisely they structure the interior.


What If Your Next Feature Didn't Feel Like Jenga?

Remember where we started? The Jenga tower. The held breath. The "don't touch that file."

You're adding a feature. In the system you have now, that means touching six files across three layers, running a test suite that takes four minutes (if it runs at all), and deploying with your fingers crossed.

In a Hexagonal codebase? You write a use case. You write an adapter. You run 200 tests in two seconds. You deploy knowing that your business logic was tested without a database in the loop. No Jenga. No held breath. No "don't touch that file."

That's what flipping the arrow gives you. Not perfection — I showed you the costs. But confidence. The kind that comes from knowing your architecture enforces the boundaries that willpower alone never could.

If your codebase feels like it's fighting you — and you recognize those 16 pains — the organization is the root cause. Not your skills. Not your framework. The structure.

It’s about building a system that serves you, rather than you serving your dependencies. When you flip the arrow, you stop being a "Prisma developer" or an "Express developer." You become a systems architect who happens to use Prisma and Express.

And that difference? It’s exactly what separates the code that decays from the code that evolves.

The next time you’re about to add a feature and you feel that familiar Jenga-style anxiety... stop. Pull back. Look at the arrows.

Maybe it’s time to stop fighting your architecture and start flipping it.