Hexagonal Architecture: Why Your Domain Logic Shouldn't Know About Your Database
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.
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.
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:
PostgresOrderRepositoryandInMemoryOrderRepositoryboth implementOrderRepositoryStripePaymentGatewayandMockPaymentGatewayboth implementPaymentGatewayRabbitMQEventPublisherandSyncEventPublisherboth implementEventPublisher
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
-
Dependency direction matters more than layers. Always point inward toward the domain.
-
Ports are interfaces your domain needs. Adapters are implementations the infrastructure provides.
-
If you’re using DI, you’re already doing a version of this. Formalize it by being strict about dependency flow.
-
Testing becomes embarrassingly simple when your domain has zero infrastructure dependencies.
-
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.
-
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?
-
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
Anemic vs Rich Domain Models
Understand the difference between Anemic and Rich Domain Models in Domain-Driven Design. Learn which approach to choose for better code organization and where to put business logic.
D (Dependency Inversion) from SOLID
Understand the Dependency Inversion Principle from SOLID. Learn why high-level modules should depend on abstractions, not concrete implementations, and how DIP enables testable, flexible architecture.
O (Open/Closed) from SOLID
Master the Open/Closed Principle from SOLID. Learn how to design software that is open for extension but closed for modification, so new features don't break existing code.