← Blog
tutorials

Hexagonal Architecture | A Practical Guide for Modern Applications

Learn what hexagonal architecture is, why it matters, and how to implement it. A complete guide with real examples for building maintainable, testable software.

Ryveris Team ·
Hexagonal Architecture | A Practical Guide for Modern Applications

Hexagonal architecture is a software design pattern that isolates your core business logic from external systems like databases, APIs, and user interfaces. The idea is simple: your domain code should never depend on infrastructure. Infrastructure depends on the domain.

The pattern was introduced by Alistair Cockburn in 2005. You might also hear it called “Ports and Adapters.” Both names describe the same concept.

Why It Exists

Most applications start the same way. Business logic gets tangled with database queries, HTTP handlers, and third-party service calls. It works at first. Then the codebase grows, and three things happen:

  1. Testing becomes painful. You can’t test business rules without spinning up a database or mocking half the framework.
  2. Changing infrastructure is risky. Swapping a payment provider or migrating from REST to GraphQL means rewriting business logic.
  3. The code is hard to reason about. Nobody can tell where the domain ends and the infrastructure begins.

Hexagonal architecture solves all three problems by enforcing a single rule: dependencies always point inward.

The Core Concepts

Think of your application as three concentric layers.

The Domain (Center)

This is your business logic. Pure functions, entities, value objects, and domain services. It has zero dependencies on frameworks, databases, or external libraries. It speaks its own language.

For an e-commerce system, this layer contains concepts like Order, Product, PricingRule, and InventoryPolicy. These objects know nothing about SQL, HTTP, or JSON.

Ports (Middle Layer)

Ports are interfaces that define how the outside world interacts with the domain. They are contracts, not implementations.

There are two types:

  • Inbound ports (driving). Define what the application can do. Think of them as use cases. “Place an order.” “Cancel a subscription.” “Generate an invoice.”
  • Outbound ports (driven). Define what the application needs from the outside world. “Save an order.” “Send a notification.” “Fetch the exchange rate.”

Ports are part of the domain layer. They are written in domain language, not infrastructure language. You write OrderRepository, not PostgresOrderDAO.

Adapters (Outer Layer)

Adapters are the concrete implementations that connect the real world to your ports.

  • Inbound adapters translate external requests into domain calls. A REST controller, a GraphQL resolver, a CLI command, a message queue consumer. All of these are inbound adapters.
  • Outbound adapters implement the outbound port interfaces. A PostgreSQL repository, an SMTP email sender, a Stripe payment gateway. All of these are outbound adapters.

The key insight: adapters depend on ports. Ports never depend on adapters. This is what makes the whole pattern work.

A Concrete Example

Let’s say you’re building an order placement feature. Here’s how the layers break down.

Domain (entities and rules):

// domain/Order.ts
class Order {
  readonly id: string;
  readonly items: OrderItem[];
  readonly status: OrderStatus;

  calculateTotal(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.price.multiply(item.quantity)),
      Money.zero()
    );
  }

  confirm(): Order {
    if (this.items.length === 0) {
      throw new EmptyOrderError();
    }
    return new Order({ ...this, status: "confirmed" });
  }
}

Inbound port (use case interface):

// ports/inbound/PlaceOrder.ts
interface PlaceOrder {
  execute(command: PlaceOrderCommand): Promise<OrderConfirmation>;
}

Outbound ports (what the use case needs):

// ports/outbound/OrderRepository.ts
interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
}

// ports/outbound/PaymentGateway.ts
interface PaymentGateway {
  charge(amount: Money, method: PaymentMethod): Promise<PaymentResult>;
}

// ports/outbound/NotificationSender.ts
interface NotificationSender {
  sendOrderConfirmation(order: Order): Promise<void>;
}

Application service (implements the inbound port):

// application/PlaceOrderService.ts
class PlaceOrderService implements PlaceOrder {
  constructor(
    private orders: OrderRepository,
    private payments: PaymentGateway,
    private notifications: NotificationSender
  ) {}

  async execute(command: PlaceOrderCommand): Promise<OrderConfirmation> {
    const order = Order.create(command.items);
    const confirmed = order.confirm();

    const payment = await this.payments.charge(
      confirmed.calculateTotal(),
      command.paymentMethod
    );

    if (!payment.successful) {
      throw new PaymentFailedError(payment.reason);
    }

    await this.orders.save(confirmed);
    await this.notifications.sendOrderConfirmation(confirmed);

    return OrderConfirmation.from(confirmed, payment);
  }
}

Adapters (infrastructure):

// adapters/inbound/OrderController.ts
class OrderController {
  constructor(private placeOrder: PlaceOrder) {}

  async handlePost(req: Request): Promise<Response> {
    const command = PlaceOrderCommand.fromRequest(req.body);
    const confirmation = await this.placeOrder.execute(command);
    return Response.json(confirmation.toResponse(), { status: 201 });
  }
}

// adapters/outbound/PostgresOrderRepository.ts
class PostgresOrderRepository implements OrderRepository {
  async save(order: Order): Promise<void> {
    await this.db.query(
      "INSERT INTO orders (id, items, status) VALUES ($1, $2, $3)",
      [order.id, JSON.stringify(order.items), order.status]
    );
  }

  async findById(id: string): Promise<Order | null> {
    const row = await this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
    return row ? Order.fromPersistence(row) : null;
  }
}

Notice how PlaceOrderService never mentions PostgreSQL, HTTP, or any framework. It works purely with domain concepts and port interfaces.

The Dependency Rule

This is the most important rule. If you get nothing else from this article, get this:

Source code dependencies must always point inward, toward the domain.

  • Adapters depend on ports. Never the reverse.
  • Ports depend on domain entities. Never on adapters.
  • The domain depends on nothing external.

This is enforced through dependency injection. Your composition root (the entry point of the application) wires everything together:

// main.ts (composition root)
const orderRepo = new PostgresOrderRepository(db);
const payments = new StripePaymentGateway(stripeClient);
const notifications = new EmailNotificationSender(mailer);

const placeOrder = new PlaceOrderService(orderRepo, payments, notifications);
const controller = new OrderController(placeOrder);

The domain layer never imports from the adapters layer. If you see an import from adapters/ inside domain/ or ports/, something is wrong.

Project Structure

A clean folder structure makes the boundaries visible:

src/
  domain/
    Order.ts
    OrderItem.ts
    Money.ts
    errors/
      EmptyOrderError.ts
      PaymentFailedError.ts
  ports/
    inbound/
      PlaceOrder.ts
      CancelOrder.ts
    outbound/
      OrderRepository.ts
      PaymentGateway.ts
      NotificationSender.ts
  application/
    PlaceOrderService.ts
    CancelOrderService.ts
  adapters/
    inbound/
      http/
        OrderController.ts
      cli/
        PlaceOrderCommand.ts
    outbound/
      persistence/
        PostgresOrderRepository.ts
      payment/
        StripePaymentGateway.ts
      notification/
        EmailNotificationSender.ts
  main.ts

Every developer on the team can look at this tree and understand where to put new code.

Testing Benefits

This is where hexagonal architecture really pays off.

Unit testing the domain:

test("order calculates total correctly", () => {
  const order = Order.create([
    { product: "Widget", price: Money.eur(10), quantity: 3 },
    { product: "Gadget", price: Money.eur(25), quantity: 1 },
  ]);

  expect(order.calculateTotal()).toEqual(Money.eur(55));
});

No database. No HTTP server. No mocking framework. Pure logic, pure tests.

Testing use cases with fake adapters:

test("place order charges payment and saves", async () => {
  const orders = new InMemoryOrderRepository();
  const payments = new FakePaymentGateway({ alwaysSucceeds: true });
  const notifications = new FakeNotificationSender();

  const service = new PlaceOrderService(orders, payments, notifications);

  const result = await service.execute({
    items: [{ product: "Widget", price: 10, quantity: 2 }],
    paymentMethod: { type: "card", token: "tok_test" },
  });

  expect(result.status).toBe("confirmed");
  expect(orders.savedOrders).toHaveLength(1);
  expect(payments.charges).toHaveLength(1);
  expect(notifications.sent).toHaveLength(1);
});

You test the entire use case without touching real infrastructure. The fake adapters implement the same port interfaces, so they’re interchangeable with the real ones.

Integration tests only for adapters:

test("PostgresOrderRepository saves and retrieves", async () => {
  const repo = new PostgresOrderRepository(testDb);
  const order = Order.create([
    { product: "Widget", price: Money.eur(10), quantity: 1 },
  ]);

  await repo.save(order.confirm());
  const found = await repo.findById(order.id);

  expect(found).toEqual(order.confirm());
});

Each adapter gets its own integration test. If the adapter passes its test, and the use case passes with fake adapters, the whole system works.

When to Use Hexagonal Architecture

Hexagonal architecture adds structure and indirection. That has a cost. Here’s when it’s worth it:

  • Long-lived applications. If the project will be maintained for years, the investment pays off quickly.
  • Complex business rules. If your domain has non-trivial logic that needs to be tested thoroughly.
  • Multiple entry points. If the same logic is called from a web API, a CLI, a message queue, and a cron job.
  • Infrastructure might change. If you’re not sure whether you’ll stick with your current database, cloud provider, or third-party services.
  • Multiple developers. Clear boundaries reduce merge conflicts and make code reviews faster.

When to Skip It

Not every project needs this level of structure:

  • Simple CRUD applications. If the app is mostly reading and writing data with minimal business logic, the indirection adds cost without benefit.
  • Prototypes and MVPs. Speed matters more than architecture when you’re validating an idea. Refactor later.
  • Small scripts and tools. A 200-line CLI tool doesn’t need ports and adapters.

The right amount of architecture depends on the complexity of the problem. Start simple. Introduce hexagonal boundaries when the pain of not having them becomes real.

Common Mistakes

Leaking infrastructure into the domain. If your Order entity has a @Column decorator or a toJSON() method, infrastructure has leaked in. Keep your domain objects clean.

Creating ports that mirror infrastructure. A port called SqlQuery defeats the purpose. Ports should describe what the domain needs in domain language, not how the infrastructure works.

Over-abstracting. Not everything needs a port. If you have a utility function that formats dates, just use it directly. Reserve ports for actual infrastructure boundaries.

Skipping the composition root. Without a clear place where dependencies are wired together, the pattern breaks down. Every dependency should be injected, not imported directly.

Hexagonal vs. Other Patterns

You might wonder how hexagonal architecture relates to other well-known patterns:

  • Clean Architecture (Robert C. Martin): Very similar. Clean Architecture adds more layers (entities, use cases, interface adapters, frameworks) but the core idea is the same: dependencies point inward.
  • Onion Architecture (Jeffrey Palermo): Also very similar. Onion Architecture uses concentric rings with the domain at the center. The terminology differs, but the dependency rule is identical.
  • Layered Architecture (N-tier): The traditional approach where layers stack vertically (presentation, business, data). The crucial difference: in layered architecture, the business layer typically depends on the data layer. In hexagonal architecture, that dependency is inverted.

All three modern patterns (hexagonal, clean, onion) share the same fundamental principle. The domain does not depend on infrastructure. They differ mostly in naming conventions and the number of layers they prescribe.

Getting Started

If you want to apply hexagonal architecture to an existing project, don’t try to refactor everything at once. Start with one bounded context or one feature:

  1. Identify the core business logic in that feature. Extract it into pure functions or classes with no framework dependencies.
  2. Define ports for the external dependencies that feature uses. Create interfaces in domain language.
  3. Move the existing infrastructure code into adapter classes that implement those interfaces.
  4. Wire everything together in a composition root using dependency injection.
  5. Write tests for the domain and use cases using fake adapters.

Repeat for the next feature. Over time, the hexagonal boundaries will spread across the codebase naturally.

Final Thoughts

Hexagonal architecture is not about following a rigid template. It’s about one simple idea: protect your business logic from the chaos of the outside world. Databases change. Frameworks fall out of fashion. APIs get deprecated. Your business rules should survive all of that.

The pattern works because it aligns with how software actually evolves. Requirements change constantly. Infrastructure changes periodically. But the core rules of your business tend to be the most stable part of the system. Build around that stability.

If you’re building a product that needs to last, hexagonal architecture is one of the best investments you can make in your codebase.


Need help designing or refactoring your application’s architecture? Get in touch. We help teams build software that stays maintainable as it grows.

architecturehexagonalports and adaptersclean codesoftware design

Let's build your next project.

Book a free 30-minute call. We'll discuss your goals, timeline, and the best approach. No strings attached.

Book a discovery call hello@ryveris.com