Hexagonal Architecture (Ports & Adapters)

Put the application in the centre and plug everything else in through ports and adapters. Driving vs driven sides, a complete TypeScript example, testing with fake adapters, and how to structure the folders.

Intermediate⏱ 7 min readLesson 5 of 13#architecture#hexagonal#ports-and-adapters#testing#typescript

The big idea

A games console has ports: HDMI for a screen, USB for controllers, a network port. The console doesn't care whether you plug in a 4K TV or an old monitor, a wired or wireless controller. Each device just needs an adapter (a cable or dongle) that fits the port.

Hexagonal Architecture, also called Ports & Adapters (Alistair Cockburn, 2005), treats your application the same way. The application core sits in the middle; everything else (web UI, REST API, tests, database, email, message queues) plugs in through ports, using adapters.

Hexagonal architecture: the application core in the middle, adapters plugged into ports on every sideHexagonal architecture: the application core in the middle, adapters plugged into ports on every side

πŸ’‘ Why a hexagon? No magic in the number six. Cockburn drew a hexagon to escape the "top-to-bottom layers" picture, and to show there are many sides where things can plug in, all equally "outside".

Core vocabulary

TermMeaningExample
Application coreBusiness logic: domain model + use cases. No I/O, no frameworksOrder, PlaceOrder
PortAn interface defined by the core, in its own languageForPlacingOrders, OrderRepository
AdapterCode that connects a real technology to a portExpress controller, Postgres repository
Driving (primary) sideActors that call the applicationWeb UI, REST API, CLI, tests, a message consumer
Driven (secondary) sideThings the application callsDatabase, payment provider, email, event bus, clock
Drawing diagram…

Direction of dependencies: adapters depend on ports; the core depends on nothing outside itself. Exactly the same rule as Clean Architecture, drawn differently.

A complete example: a library loan system

1. The core: domain + ports + use case

// core/domain/Loan.ts
export class Loan {
  constructor(readonly bookId: string, readonly memberId: string, readonly dueDate: Date) {}
  isOverdue(today: Date) { return today > this.dueDate; }
}

// core/ports/driven.ts β€” what the core NEEDS from the outside world
export interface BookCatalog { isAvailable(bookId: string): Promise<boolean>; markBorrowed(bookId: string): Promise<void>; }
export interface LoanRepository { save(loan: Loan): Promise<void>; countActiveFor(memberId: string): Promise<number>; }
export interface Notifier { loanConfirmed(memberId: string, loan: Loan): Promise<void>; }
export interface Clock { now(): Date; }

// core/ports/driving.ts β€” what the core OFFERS to the outside world
export interface ForBorrowingBooks {
  borrow(memberId: string, bookId: string): Promise<Loan>;
}

// core/BorrowBook.ts β€” implements the driving port using the driven ports
const MAX_ACTIVE_LOANS = 3;
const LOAN_DAYS = 14;

export class BorrowBook implements ForBorrowingBooks {
  constructor(
    private readonly catalog: BookCatalog,
    private readonly loans: LoanRepository,
    private readonly notifier: Notifier,
    private readonly clock: Clock,
  ) {}

  async borrow(memberId: string, bookId: string): Promise<Loan> {
    if (!(await this.catalog.isAvailable(bookId))) throw new DomainError("Book is not available");
    if ((await this.loans.countActiveFor(memberId)) >= MAX_ACTIVE_LOANS) {
      throw new DomainError(`Members can borrow at most ${MAX_ACTIVE_LOANS} books`);
    }

    const due = new Date(this.clock.now().getTime() + LOAN_DAYS * 24 * 60 * 60 * 1000);
    const loan = new Loan(bookId, memberId, due);

    await this.catalog.markBorrowed(bookId);
    await this.loans.save(loan);
    await this.notifier.loanConfirmed(memberId, loan);
    return loan;
  }
}

πŸ’‘ Even time is behind a port (Clock). "What's today's date?" is input from the outside world, and faking it makes tests deterministic.

2. Driving adapters: the ways in

// adapters/driving/http.ts β€” REST
export function loanRoutes(app: Express, borrowing: ForBorrowingBooks) {
  app.post("/members/:memberId/loans", async (req, res) => {
    const loan = await borrowing.borrow(req.params.memberId, req.body.bookId);
    res.status(201).json({ bookId: loan.bookId, dueDate: loan.dueDate.toISOString() });
  });
}

// adapters/driving/cli.ts β€” the same use case from a terminal
export async function borrowFromCli(borrowing: ForBorrowingBooks, [memberId, bookId]: string[]) {
  const loan = await borrowing.borrow(memberId, bookId);
  console.log(`βœ… Borrowed ${bookId}, due ${loan.dueDate.toDateString()}`);
}

3. Driven adapters: the ways out

// adapters/driven/PostgresLoanRepository.ts
export class PostgresLoanRepository implements LoanRepository {
  constructor(private readonly db: Pool) {}
  async save(loan: Loan) {
    await this.db.query("INSERT INTO loans (book_id, member_id, due_date) VALUES ($1, $2, $3)", [loan.bookId, loan.memberId, loan.dueDate]);
  }
  async countActiveFor(memberId: string) {
    const { rows } = await this.db.query("SELECT count(*)::int AS n FROM loans WHERE member_id = $1 AND returned_at IS NULL", [memberId]);
    return rows[0].n;
  }
}

// adapters/driven/InMemoryLoanRepository.ts β€” for tests and local demos
export class InMemoryLoanRepository implements LoanRepository {
  loans: Loan[] = [];
  async save(loan: Loan) { this.loans.push(loan); }
  async countActiveFor(memberId: string) { return this.loans.filter((l) => l.memberId === memberId).length; }
}

// adapters/driven/EmailNotifier.ts
export class EmailNotifier implements Notifier {
  constructor(private readonly mailer: Mailer) {}
  loanConfirmed(memberId: string, loan: Loan) {
    return this.mailer.send(memberId, `Your loan is due on ${loan.dueDate.toDateString()}`);
  }
}

4. Configurator: plug it all together

// main.ts
const borrowing = new BorrowBook(
  new HttpBookCatalog(process.env.CATALOG_URL!),
  new PostgresLoanRepository(pool),
  new EmailNotifier(sendgrid),
  { now: () => new Date() },
);
loanRoutes(app, borrowing);

Testing: swap the adapters

The test is just another driving adapter, and it plugs in fake driven adapters:

test("refuses a 4th loan", async () => {
  const loans = new InMemoryLoanRepository();
  loans.loans = [1, 2, 3].map((n) => new Loan(`book-${n}`, "ana", new Date("2026-10-10")));
  const borrowing = new BorrowBook(
    { isAvailable: async () => true, markBorrowed: async () => {} },
    loans,
    { loanConfirmed: async () => {} },
    { now: () => new Date("2026-09-28") },
  );

  await expect(borrowing.borrow("ana", "book-4")).rejects.toThrow("at most 3 books");
});
Drawing diagram…

The core is tested exactly as it runs in production, in milliseconds, with no database or network.

Folder structure

src/
β”œβ”€β”€ core/
β”‚   β”œβ”€β”€ domain/            # Loan, Book, rules
β”‚   β”œβ”€β”€ ports/
β”‚   β”‚   β”œβ”€β”€ driving.ts     # ForBorrowingBooks, ForReturningBooks
β”‚   β”‚   └── driven.ts      # LoanRepository, BookCatalog, Notifier, Clock
β”‚   └── BorrowBook.ts      # use cases implementing driving ports
β”œβ”€β”€ adapters/
β”‚   β”œβ”€β”€ driving/           # http.ts, cli.ts, kafka-consumer.ts
β”‚   └── driven/            # postgres/, in-memory/, email/, http-catalog/
└── main.ts                # configurator: wires adapters to ports

Naming ports well

Cockburn suggests naming ports after their purpose, as "For…ing":

SidePort nameAdapters
DrivingForBorrowingBooksREST controller, CLI, GraphQL resolver, test
DrivingForManagingCatalogAdmin UI, import job
DrivenForStoringLoans (LoanRepository)Postgres, DynamoDB, in-memory
DrivenForNotifyingMembers (Notifier)Email, SMS, push, console log

Ports speak the language of the domain ("loanConfirmed"), never the language of the technology ("sendSmtpMessage").

Hexagonal vs layered, in one picture

Drawing diagram…

In a layered app, business code depends on the data layer. In hexagonal, the data layer is just another adapter that depends on the business.

When to use it

βœ… Business logic that must be tested thoroughly, multiple entry points (API + CLI + queue consumers), external services you might swap (payment providers, email, storage), long-lived systems.

❌ Thin CRUD apps with almost no logic: ports around every table add ceremony without benefit.

Key takeaways

  • The application core is in the middle; everything else plugs in through ports using adapters.
  • Driving adapters call the core (HTTP, CLI, tests, consumers); driven adapters are called by it (DB, email, payments, clock).
  • Ports are interfaces owned by the core, named in domain language.
  • Tests are just another driving adapter using fake driven adapters: fast and realistic.
  • Same Dependency Rule as Clean Architecture: nothing in the core imports an adapter.