Skip to main content

On This Page

Hexagonal Architecture: Why Your Domain Logic Shouldn't Know About Your Database

8 min read
Share

TL;DR

Hexagonal Architecture (Ports & Adapters) inverts the traditional layered approach by putting your domain logic at the center and making everything else, databases, APIs, message queues, pluggable through interfaces. Your business rules expose ports (interfaces), and infrastructure provides adapters (implementations). This means you can test your core logic with in-memory implementations, swap PostgreSQL for DynamoDB without touching domain code, and support REST, GraphQL, or gRPC clients without architecture gymnastics. If you’re using dependency injection already, you’re halfway there without knowing it.

The Problem with Traditional Layered Architecture

Most codebases start with the classic three-tier stack: Presentation → Domain → Infrastructure. Dependencies flow downward like a waterfall. Your UI depends on your service layer, your service layer depends on your repository, and your repository depends on whatever ORM or database client you picked on day one.

Layered Architecture

This creates a coupling nightmare. Want to test business logic? Better spin up a database or write elaborate mocks. Want to switch from MySQL to MongoDB? Hope you didn’t leak SQL queries into your domain. Need to support both REST and gRPC? Prepare for a refactoring spree.

The real issue: your infrastructure choices constrain your domain model. Your business rules shouldn’t care whether data lives in Postgres, Redis, or a JSON file. But in layered architecture, they do, because the domain layer directly depends on the infrastructure layer.

Dependency Injection Changed Everything (Quietly)

Here’s what actually happened in most modern codebases: teams claimed they were doing layered architecture, but dependency injection frameworks like Spring, ASP.NET Core, or even manual DI containers inverted the control flow.

Instead of:

class OrderService {
    private OrderRepository repo = new PostgresOrderRepository();
}

We started doing:

class OrderService {
    private final OrderRepository repo;

    public OrderService(OrderRepository repo) {
        this.repo = repo;
    }
}

Suddenly, the domain doesn’t depend on infrastructure anymore, it depends on an interface. The wiring happens at the edges, in your composition root. You’ve been doing a flavor of Hexagonal Architecture without calling it that.

How Hexagonal Architecture Actually Works

The core idea: organize your system in layers where dependencies point inward.

Hexagonal Architecture

Inner Layer: Domain (The Hexagon)

This is your business logic, use cases, entities, domain events, the stuff that would exist even if you rewrote the entire stack in a different language. It exposes ports: interfaces that define what the domain needs from the outside world.

Example ports:

  • OrderRepository (persistence)
  • PaymentGateway (external service)
  • EventPublisher (messaging)
  • EmailService (notifications)

The domain declares these interfaces but never implements them. That’s the outer layer’s job.

Outer Layer: Adapters

Adapters implement the ports. You can have multiple adapters for the same port:

  • PostgresOrderRepository and InMemoryOrderRepository both implement OrderRepository
  • StripePaymentGateway and MockPaymentGateway both implement PaymentGateway
  • RabbitMQEventPublisher and SyncEventPublisher both implement EventPublisher

Adapters also handle incoming requests: REST controllers, GraphQL resolvers, CLI commands, message queue consumers. Each client gets its own adapter that translates external protocols into domain operations.

The Crucial Difference

In traditional layered architecture:

UI → Service (Domain) → Repository (Infrastructure)

Domain depends on infrastructure.

In Hexagonal Architecture:

UI Adapter → Domain ← Infrastructure Adapter
         ↓           ↑
    Both depend on Domain's interfaces

Everything depends on the domain. Infrastructure and UI are implementation details.

Why This Matters in Practice

1. Testing Becomes Trivial

Your domain logic tests don’t need Docker containers, test databases, or mocking frameworks. Just pass in-memory implementations:

@Test
void shouldApplyDiscountToOrder() {
    var repo = new InMemoryOrderRepository();
    var service = new OrderService(repo, new FakePaymentGateway());

    var order = service.createOrder(userId, items);
    service.applyDiscount(order.id(), "SAVE20");

    assertEquals(80.0, repo.findById(order.id()).totalPrice());
}

No @SpringBootTest, no database migrations, no fixture cleanup. Tests run in milliseconds.

2. Technology Decisions Are Deferred (and Reversible)

Starting a new project? You can build and validate the entire domain model with in-memory adapters before choosing a database. When you finally pick Postgres (or Dynamo, or whatever), the domain code doesn’t change, you just wire in a different adapter.

Already in production? Migrating from MySQL to Cassandra means writing a new adapter and swapping the wiring. Zero changes to business logic.

3. Multi-Client Support Is Free

Need to expose the same functionality via REST, GraphQL, and gRPC? Each gets its own adapter calling the same domain services. No duplication, no “API layer” vs “internal layer” split.

RestOrderController  ────┐
GraphQLOrderResolver ────┤──→ OrderService (Domain)
GrpcOrderService     ────┘

4. Domain Events Decouple Naturally

When an order is placed, the domain publishes an OrderPlacedEvent through the EventPublisher port. In production, that’s RabbitMQ. In tests, it’s a spy that captures events. In local dev, it’s synchronous in-memory dispatch.

The domain doesn’t know or care. It just publishes to an interface.

Common Misconceptions

“This is just Clean Architecture / Onion Architecture”
Correct. They’re variations of the same dependency inversion principle. Hexagonal Architecture (2005) predates them, but the ideas converged. Use whichever name your team prefers.

“You need a framework to do this”
No. You need discipline. Frameworks like Spring make wiring easier, but you can do this in plain Java, Go, Python, anything with interfaces or protocols.

“This adds too much boilerplate”
Initially, yes. You write more interfaces. But when you add the third API client or swap databases for the second time, you’ll wish you’d started with ports and adapters.

“The hexagon shape is important”
It’s not. Alistair Cockburn used a hexagon to emphasize symmetry, no difference between “input” adapters (HTTP, CLI) and “output” adapters (database, queue). The shape is a teaching aid, not a specification.

When to Use This (and When Not To)

Use Hexagonal Architecture when:

  • You’re building software where business logic is non-trivial and will evolve
  • You need to support multiple clients (web, mobile API, batch jobs)
  • You want fast, isolated unit tests for your domain
  • You might change infrastructure (database, message broker) later
  • You’re doing Domain-Driven Design (this architecture is built for it)

Skip it when:

  • You’re building a simple CRUD API with no real business logic
  • The entire app is a thin wrapper around database operations
  • You’re prototyping and will throw away the code in two weeks
  • Your team is junior and struggling with basic abstractions

Don’t over-engineer. If your “domain” is just user.save() and user.delete(), layered architecture is fine.

Practical Example: Order Processing

// Domain Layer (Inner)
public interface OrderRepository {
    Order save(Order order);
    Optional<Order> findById(OrderId id);
}

public interface PaymentGateway {
    PaymentResult charge(Amount amount, PaymentMethod method);
}

public class OrderService {
    private final OrderRepository orders;
    private final PaymentGateway payments;

    public OrderService(OrderRepository orders, PaymentGateway payments) {
        this.orders = orders;
        this.payments = payments;
    }

    public Order placeOrder(UserId userId, List<LineItem> items) {
        var order = Order.create(userId, items);
        var result = payments.charge(order.total(), order.paymentMethod());

        if (result.isSuccess()) {
            order.markAsPaid();
            return orders.save(order);
        }
        throw new PaymentFailedException(result.errorCode());
    }
}

// Adapters Layer (Outer)
class PostgresOrderRepository implements OrderRepository {
    // JDBC/JPA implementation
}

class StripePaymentGateway implements PaymentGateway {
    // Stripe API calls
}

class RestOrderController {
    private final OrderService service;

    @PostMapping("/orders")
    ResponseEntity<OrderDTO> create(@RequestBody CreateOrderRequest req) {
        var order = service.placeOrder(req.userId(), req.items());
        return ok(OrderDTO.from(order));
    }
}

The OrderService has no idea it’s talking to Postgres or Stripe. Swap in DynamoDB and PayPal by changing the wiring in your DI container, zero changes to OrderService.

Integration with Domain-Driven Design

Hexagonal Architecture is practically mandatory if you’re doing DDD. Your bounded contexts map to hexagons, each with their own ports and adapters. Aggregates, entities, and value objects live in the inner layer. Repositories are ports. Anti-corruption layers are adapters that translate between contexts.

This is why the pattern has seen a resurgence, DDD + microservices + event-driven architectures all benefit from isolating domain logic from infrastructure chaos.

Key Takeaways

  1. Dependency direction matters more than layers. Always point inward toward the domain.

  2. Ports are interfaces your domain needs. Adapters are implementations the infrastructure provides.

  3. If you’re using DI, you’re already doing a version of this. Formalize it by being strict about dependency flow.

  4. Testing becomes embarrassingly simple when your domain has zero infrastructure dependencies.

  5. Design for use cases, not clients. The domain shouldn’t know whether it’s serving REST, GraphQL, or a CLI. That’s what adapters are for.

  6. Technology swaps are cheap when they’re just adapter rewrites. Your Postgres adapter is 200 lines. Your domain is 5,000 lines. Which would you rather rewrite?

  7. Don’t hexagon-ify CRUD apps. If your app is thin glue between a UI and a database, this is overkill.

Continue reading

Next article

Anemic vs Rich Domain Models

Related Content