Enable javascript in your browser for better experience. Need to know to enable it? Go here.

Hexagonal architecture explained through a practical example

Imagine building a pizza delivery app: it starts simple with a few basic controllers and services, but as your business grows, so does the technical noise. Suddenly, you’re juggling Firebase notifications, Google Maps APIs, Stripe payments and even smart oven integrations.

 

Over time, core business rules often get buried under infrastructure concerns like SDKs, API integrations and JSON mapping logic. This is the "layered architecture trap," and it’s why we need a better way to build.

 

What is hexagonal architecture?

 

Hexagonal architecture (sometimes called ‘ports and adapters’ architecture) separates underlying business logic from infrastructure concerns such as databases, APIs and external services. In the context of our pizza app example we divide the application into two distinct areas, the inside (core) and the outside (infrastructure).

 

In our pizza app example, the business logic handles ordering rules, while external layers manage payments, notifications and persistence.

 

We divide the application into two distinct areas, the inside (core) and the outside (infrastructure).

 

The inside is where your core business logic (like ordering and pricing rules) lives. It’s pure code that doesn't know if an order came from a website, an iPhone or a smart fridge. The outside is where the tools live, such as your SQL database, the Stripe API or an SMS gateway.

 

The golden rule is that the core business logic should never directly depend on infrastructure tools like payment providers or databases. Changing your payment provider from Stripe to PayPal should never break your logic for "calculate total price".

 

The "plug and socket" system

 

To let the inside talk to the outside without creating a mess, we use ports and adapters.

 

A port defines what the application needs or exposes without specifying how it will be implemented. For example, the application may require a way to process payments, but it shouldn’t care whether that’s handled by Stripe, PayPal or another provider.

 

  • Inbound port (driving): Defines how the world interacts with your app, such as a PlaceOrderUseCase interface.
  • Outbound port (driven): Defines what the app needs from the world, like a PaymentProcessor or OrderRepository.

 

An adapter is the implementation that connects the application to a specific external tool or framework.

 

  • Inbound adapter: Converts a web request into a command the core understands, like a PizzaWebController.
  • Outbound adapter: Implements the contract using a specific tool, like a StripePaymentAdapter or PostgresOrderRepository.

The heart of the kitchen

 

Inside the application core, code is organized into two main layers:

 

Domain layer: This acts like a recipe book for the business. It contains your entities (like a PizzaOrder with a unique ID and lifecycle) and ensures your business invariants are never broken. For example, an order cannot be "out for delivery" if it hasn't been baked.

 

Application layer: This is the "kitchen manager". It coordinates workflows by fetching data through ports, invoking domain logic and saving results, but it does not contain the business rules itself.

 

Why this matters

 

By separating business logic from infrastructure, you gain three major advantages:

 

  1. Plug-and-play: You can swap databases or SMS providers in an afternoon without touching your core logic.

  2. Testable: You can test the entire checkout flow without actually charging a credit card.

  3. Resilient: External system failures become easier to isolate and manage.

 

Bringing order with domain-driven design

 

If our core logic is a tangled mess of spaghetti code, the architectural boundary won't save us. To ensure our application remains maintainable as it scales from a local shop to a global franchise, we need to apply domain-driven design (DDD) to organize the kitchen. While hexagonal architecture protects the system boundary, DDD helps structure the business logic inside that boundary.

 

Entities and value objects

 

Inside the domain layer, not every piece of data is treated the same. Distinguishing between entities and value objects is our first step toward clarity. 

 

An entity has a unique identity that persists over time. For example, "Order #542" is an entity. Even if some order details change, it remains the same order because its identity persists over time.

 

A value object, on the other hand, is defined only by its attributes. "Extra cheese" doesn't need a unique ID; if you swap one extra cheese for another, nothing changes. By treating these as immutable value objects, we reduce bugs and make our code easier to reason about.

 

Defining the boundaries: The aggregate root

 

In a busy kitchen, you want all changes related to an order to pass through a single controlled entry point. In DDD, that entry point is the aggregate root. For example, status updates, line items and total calculations should all be managed through the order aggregate. It’s the gateway to all internal components (like individual pizzas or line items). Any change to the state of the order must go through the aggregate root to ensure business invariants are always enforced.

 

The decision matrix

 

One of the biggest challenges in software design is deciding where a specific rule belongs. A useful rule of thumb is:

Question

If yes...

If no...

Does it need to call a database or Stripe?

Application service 

Look at the next question.

Does it compare multiple orders or drivers?

Domain service 

Look at the next question.

Does it only need its own toppings and size?

Domain entity 

Re-evaluate the requirement.

For example, a DeliveryFeeCalculator that checks distance and driver availability is too complex for a single Order object. This logic belongs in a domain service because it spans multiple business concepts rather than belonging to a single domain object.

 

Combining hexagonal architecture and DDD

 

Together, hexagonal architecture and DDD create systems that are easier to test, maintain and evolve as business complexity grows.

 

By isolating the "how" (infrastructure) from the "what" (domain), and then carefully organizing the "what" into entities and aggregates, we create a system that’s adaptable. Whether you’re swapping out your payment provider or changing your discount logic for the holiday season, your codebase remains a clean, testable representation of your business.

Whether you call it hexagonal or something else, the outcome remains constant: your core business logic remains protected from infrastructure complexity. Ultimately, don't let the semantics distract you. 

 

Put it into practice

 

Theory is a starting point, but implementation provides the evidence. I’ve prepared a complete sample codebase implementing this pizza delivery hexagon using Spring Boot, featuring separate packages for domain, application and infrastructure. 

 

How do you currently handle business logic that spans across multiple entities in your own projects? Does a "domain service" approach feel like it would reduce the complexity of your services?

 

View the PizzaCo Hexagonal Sample on GitHub.

 

Disclaimer: The statements and opinions expressed in this article are those of the author(s) and do not necessarily reflect the positions of Thoughtworks.

Explore a snapshot of today's tech landscape