Hexagonal Architecture (Ports & Adapters): Clean Boundaries

In layered architecture, dependencies flow in one direction: presentation → business logic → data access. This works well until you realize that your business logic — the most important part of your application — depends on the database layer. Change the database, and the core logic needs to change too.
Hexagonal architecture (also called Ports & Adapters) flips this relationship. The business logic sits at the center and depends on nothing. Everything external — databases, APIs, UIs, message queues — connects through well-defined interfaces called ports, with concrete implementations called adapters.
This means you can swap PostgreSQL for MongoDB, replace a REST API with gRPC, or switch from Kafka to RabbitMQ — all without touching a single line of business logic.
In this post, we'll cover:
✅ The problem with traditional dependency direction
✅ Hexagonal architecture explained: the core, ports, and adapters
✅ Driving (primary) vs driven (secondary) adapters
✅ Dependency inversion at the heart of the pattern
✅ Practical folder structure and implementation
✅ Testing with mock adapters
✅ Comparing hexagonal to layered architecture
✅ Integrating with dependency injection (Spring, NestJS)
✅ Real-world example: swapping databases and message brokers
✅ Common implementation mistakes
The Problem: Business Logic Depends on Infrastructure
In a typical layered application, the dependency chain looks like this:
The Service layer calls the Repository layer directly. The Repository uses JPA or TypeORM, which are tightly coupled to the database technology. This creates problems:
1. Business logic depends on infrastructure
// Service depends directly on JPA repository
@Service
public class OrderService {
private final OrderJpaRepository orderRepository; // JPA dependency!
public Order createOrder(CreateOrderRequest request) {
// Business logic mixed with infrastructure concerns
OrderEntity entity = new OrderEntity(); // JPA entity!
entity.setCustomerId(request.getCustomerId());
entity.setStatus("PENDING");
return orderRepository.save(entity); // JPA method!
}
}Want to switch from PostgreSQL to MongoDB? You need to rewrite the service layer. Want to use a different ORM? Same problem.
2. Testing requires a database
// Hard to test — needs a real database or H2
@SpringBootTest
class OrderServiceTest {
@Autowired OrderService orderService; // Needs Spring context + DB
@Test
void shouldCreateOrder() {
// This test is slow because it needs a database
Order order = orderService.createOrder(request);
assertNotNull(order.getId());
}
}3. Framework lock-in
JPA annotations, Spring-specific classes, and framework conventions leak into the business logic. Migrating to a different framework means rewriting core logic.
Hexagonal Architecture: The Core Idea
Hexagonal architecture solves this by inverting the dependency direction. The business logic (the core) defines interfaces (the ports) that external systems must implement (through adapters).
The hexagon represents the application boundary. Everything inside the hexagon is your business logic. Everything outside is infrastructure.
Why a Hexagon?
Alistair Cockburn (the pattern's creator) chose a hexagon because it has enough sides to represent the various types of external interactions — but the exact shape doesn't matter. The key insight is that there is no top or bottom. Unlike layered architecture where things flow from top to bottom, hexagonal architecture has the core in the center with ports on all sides.
The Three Parts
1. The Core (Business Logic)
The innermost part. Contains domain entities, business rules, and use cases. Has zero dependencies on external frameworks or infrastructure.
// Pure domain entity — no framework annotations
public class Order {
private final String id;
private final String customerId;
private OrderStatus status;
private final List<OrderItem> items;
private BigDecimal totalAmount;
public void confirm() {
if (status != OrderStatus.PLACED) {
throw new IllegalStateException(
"Can only confirm orders in PLACED status, current: " + status
);
}
this.status = OrderStatus.CONFIRMED;
}
public void cancel(String reason) {
if (status == OrderStatus.SHIPPED) {
throw new IllegalStateException("Cannot cancel shipped orders");
}
this.status = OrderStatus.CANCELLED;
}
public void addItem(OrderItem item) {
items.add(item);
recalculateTotal();
}
private void recalculateTotal() {
this.totalAmount = items.stream()
.map(i -> i.price().multiply(BigDecimal.valueOf(i.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}Notice: no @Entity, no @Id, no @Column. This is a pure domain object with business logic only.
2. Ports (Interfaces)
Ports are interfaces defined by the core that describe what it needs from the outside world, without specifying how.
3. Adapters (Implementations)
Adapters are concrete implementations of ports. They translate between the core's language and the external system's language.
Ports: Driving vs Driven
Ports come in two flavors based on who initiates the interaction.
Driving Ports (Primary / Inbound)
Driving ports define how the outside world interacts with the application. They are the application's API — the use cases it exposes.
// Driving port — defines what the application CAN DO
public interface OrderService {
String placeOrder(PlaceOrderCommand command);
void confirmOrder(String orderId);
void cancelOrder(String orderId, String reason);
OrderDetails getOrderDetails(String orderId);
}Who calls driving ports? REST controllers, GraphQL resolvers, CLI commands, message consumers — anything that triggers the application to do something.
Driven Ports (Secondary / Outbound)
Driven ports define what the application needs from the outside world. They are the application's requirements — the infrastructure it depends on.
// Driven port — defines what the application NEEDS
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(String id);
List<Order> findByCustomerId(String customerId);
}
public interface PaymentGateway {
PaymentResult charge(String customerId, BigDecimal amount);
PaymentResult refund(String paymentId);
}
public interface NotificationSender {
void sendOrderConfirmation(String customerId, String orderId);
void sendOrderCancellation(String customerId, String orderId);
}
public interface EventPublisher {
void publish(DomainEvent event);
}Who implements driven ports? Database adapters, HTTP clients, message producers, email senders — the concrete infrastructure.
The Direction of Dependencies
This is the critical rule of hexagonal architecture:
- Driving adapters depend on the core (they call core interfaces)
- The core depends on nothing external (it only defines interfaces)
- Driven adapters depend on the core (they implement core interfaces)
All arrows point inward. The core never references anything outside itself.
Practical Implementation
Let's build a complete order system using hexagonal architecture with Spring Boot.
Project Structure
src/main/java/com/example/orders/
├── domain/ # THE CORE — no framework dependencies
│ ├── model/
│ │ ├── Order.java # Domain entity
│ │ ├── OrderItem.java # Value object
│ │ ├── OrderStatus.java # Enum
│ │ └── DomainEvent.java # Base event
│ ├── port/
│ │ ├── in/ # Driving ports (inbound)
│ │ │ └── OrderService.java # Use case interface
│ │ └── out/ # Driven ports (outbound)
│ │ ├── OrderRepository.java # Persistence interface
│ │ ├── PaymentGateway.java # Payment interface
│ │ └── EventPublisher.java # Event interface
│ ├── service/
│ │ └── OrderServiceImpl.java # Use case implementation
│ └── exception/
│ ├── OrderNotFoundException.java
│ └── InsufficientInventoryException.java
│
├── adapter/ # ADAPTERS — framework-specific
│ ├── in/ # Driving adapters (inbound)
│ │ ├── rest/
│ │ │ ├── OrderController.java # REST API
│ │ │ ├── OrderRequest.java # REST DTOs
│ │ │ └── OrderResponse.java
│ │ └── messaging/
│ │ └── OrderEventListener.java # Kafka consumer
│ └── out/ # Driven adapters (outbound)
│ ├── persistence/
│ │ ├── OrderJpaRepository.java # Spring Data JPA
│ │ ├── OrderJpaEntity.java # JPA entity
│ │ └── OrderPersistenceAdapter.java # Adapter
│ ├── payment/
│ │ └── StripePaymentAdapter.java # Stripe implementation
│ └── messaging/
│ └── KafkaEventPublisher.java # Kafka producerThe Core: Domain Model + Ports + Use Cases
// domain/model/Order.java — Pure domain entity
public class Order {
private String id;
private String customerId;
private OrderStatus status;
private List<OrderItem> items;
private BigDecimal totalAmount;
public static Order create(String customerId, List<OrderItem> items) {
Order order = new Order();
order.id = UUID.randomUUID().toString();
order.customerId = customerId;
order.status = OrderStatus.PLACED;
order.items = new ArrayList<>(items);
order.totalAmount = items.stream()
.map(OrderItem::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return order;
}
public void confirm() {
if (status != OrderStatus.PLACED) {
throw new IllegalStateException("Cannot confirm order in status: " + status);
}
status = OrderStatus.CONFIRMED;
}
public void cancel(String reason) {
if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) {
throw new IllegalStateException("Cannot cancel order in status: " + status);
}
status = OrderStatus.CANCELLED;
}
// Getters only — no setters, enforce invariants through methods
public String getId() { return id; }
public String getCustomerId() { return customerId; }
public OrderStatus getStatus() { return status; }
public List<OrderItem> getItems() { return Collections.unmodifiableList(items); }
public BigDecimal getTotalAmount() { return totalAmount; }
}
// domain/model/OrderItem.java — Value object
public record OrderItem(
String productId,
String productName,
int quantity,
BigDecimal price
) {
public BigDecimal subtotal() {
return price.multiply(BigDecimal.valueOf(quantity));
}
}// domain/port/in/OrderService.java — Driving port
public interface OrderService {
String placeOrder(PlaceOrderCommand command);
void confirmOrder(String orderId);
void cancelOrder(String orderId, String reason);
OrderDetails getOrderDetails(String orderId);
}
// domain/port/in/PlaceOrderCommand.java
public record PlaceOrderCommand(
String customerId,
List<OrderItemRequest> items
) {}
// domain/port/out/OrderRepository.java — Driven port
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(String id);
List<Order> findByCustomerId(String customerId);
}
// domain/port/out/PaymentGateway.java — Driven port
public interface PaymentGateway {
PaymentResult charge(String customerId, BigDecimal amount);
}
// domain/port/out/EventPublisher.java — Driven port
public interface EventPublisher {
void publish(DomainEvent event);
}// domain/service/OrderServiceImpl.java — Use case implementation
public class OrderServiceImpl implements OrderService {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final EventPublisher eventPublisher;
// Constructor injection — no @Autowired, no Spring annotations
public OrderServiceImpl(OrderRepository orderRepository,
PaymentGateway paymentGateway,
EventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
this.eventPublisher = eventPublisher;
}
@Override
public String placeOrder(PlaceOrderCommand command) {
// Pure business logic — no framework code
List<OrderItem> items = command.items().stream()
.map(r -> new OrderItem(r.productId(), r.productName(),
r.quantity(), r.price()))
.toList();
Order order = Order.create(command.customerId(), items);
// Charge payment through the port (not directly via Stripe)
PaymentResult payment = paymentGateway.charge(
command.customerId(), order.getTotalAmount()
);
if (!payment.isSuccess()) {
throw new PaymentFailedException(payment.errorMessage());
}
order.confirm();
// Save through the port (not directly via JPA)
orderRepository.save(order);
// Publish event through the port (not directly via Kafka)
eventPublisher.publish(new OrderPlacedEvent(
order.getId(), order.getCustomerId(), order.getTotalAmount()
));
return order.getId();
}
@Override
public void cancelOrder(String orderId, String reason) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.cancel(reason); // Domain logic validates state transition
orderRepository.save(order);
eventPublisher.publish(new OrderCancelledEvent(orderId, reason));
}
@Override
public OrderDetails getOrderDetails(String orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
return OrderDetails.from(order);
}
}Driving Adapter: REST Controller
// adapter/in/rest/OrderController.java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService; // Depends on the PORT, not the impl
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<OrderResponse> placeOrder(
@Valid @RequestBody OrderRequest request) {
// Translate REST request → domain command
PlaceOrderCommand command = new PlaceOrderCommand(
request.customerId(),
request.items().stream()
.map(i -> new OrderItemRequest(
i.productId(), i.productName(), i.quantity(), i.price()))
.toList()
);
String orderId = orderService.placeOrder(command);
return ResponseEntity.status(HttpStatus.CREATED)
.body(new OrderResponse(orderId, "Order placed successfully"));
}
@DeleteMapping("/{orderId}")
public ResponseEntity<Void> cancelOrder(
@PathVariable String orderId,
@RequestParam String reason) {
orderService.cancelOrder(orderId, reason);
return ResponseEntity.noContent().build();
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderDetailsResponse> getOrder(
@PathVariable String orderId) {
OrderDetails details = orderService.getOrderDetails(orderId);
return ResponseEntity.ok(OrderDetailsResponse.from(details));
}
}Driven Adapter: Database (JPA)
// adapter/out/persistence/OrderJpaEntity.java — JPA entity (adapter concern!)
@Entity
@Table(name = "orders")
public class OrderJpaEntity {
@Id private String id;
private String customerId;
@Enumerated(EnumType.STRING) private OrderStatus status;
private BigDecimal totalAmount;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItemJpaEntity> items;
// Mappers: domain ↔ JPA entity
public static OrderJpaEntity fromDomain(Order order) {
OrderJpaEntity entity = new OrderJpaEntity();
entity.id = order.getId();
entity.customerId = order.getCustomerId();
entity.status = order.getStatus();
entity.totalAmount = order.getTotalAmount();
entity.items = order.getItems().stream()
.map(OrderItemJpaEntity::fromDomain)
.toList();
return entity;
}
public Order toDomain() {
return Order.reconstitute(id, customerId, status, totalAmount,
items.stream().map(OrderItemJpaEntity::toDomain).toList());
}
}
// adapter/out/persistence/OrderJpaRepository.java — Spring Data interface
public interface OrderJpaRepository extends JpaRepository<OrderJpaEntity, String> {
List<OrderJpaEntity> findByCustomerId(String customerId);
}
// adapter/out/persistence/OrderPersistenceAdapter.java — THE ADAPTER
@Component
public class OrderPersistenceAdapter implements OrderRepository {
private final OrderJpaRepository jpaRepository;
public OrderPersistenceAdapter(OrderJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Order save(Order order) {
OrderJpaEntity entity = OrderJpaEntity.fromDomain(order);
jpaRepository.save(entity);
return order;
}
@Override
public Optional<Order> findById(String id) {
return jpaRepository.findById(id)
.map(OrderJpaEntity::toDomain);
}
@Override
public List<Order> findByCustomerId(String customerId) {
return jpaRepository.findByCustomerId(customerId).stream()
.map(OrderJpaEntity::toDomain)
.toList();
}
}Driven Adapter: Payment Gateway
// adapter/out/payment/StripePaymentAdapter.java
@Component
public class StripePaymentAdapter implements PaymentGateway {
private final StripeClient stripeClient;
@Override
public PaymentResult charge(String customerId, BigDecimal amount) {
try {
StripeCharge charge = stripeClient.createCharge(
customerId, amount.longValue() * 100, "usd"
);
return PaymentResult.success(charge.getId());
} catch (StripeException e) {
return PaymentResult.failure(e.getMessage());
}
}
}Driven Adapter: Event Publisher
// adapter/out/messaging/KafkaEventPublisher.java
@Component
public class KafkaEventPublisher implements EventPublisher {
private final KafkaTemplate<String, DomainEvent> kafkaTemplate;
@Override
public void publish(DomainEvent event) {
kafkaTemplate.send("order-events", event.getAggregateId(), event);
}
}Wiring It Together (Spring Configuration)
// Configuration — the only place where Spring knows about the core
@Configuration
public class OrderConfig {
@Bean
public OrderService orderService(
OrderRepository orderRepository,
PaymentGateway paymentGateway,
EventPublisher eventPublisher) {
return new OrderServiceImpl(orderRepository, paymentGateway, eventPublisher);
}
}Spring's dependency injection automatically finds the @Component adapters and injects them into the use case. The core never knows it's running inside Spring.
Testing with Mock Adapters
The biggest advantage of hexagonal architecture: testing the core without any infrastructure.
Unit Testing the Use Case
// Pure unit test — no Spring, no database, no Kafka
class OrderServiceImplTest {
private OrderRepository orderRepository;
private PaymentGateway paymentGateway;
private EventPublisher eventPublisher;
private OrderService orderService;
@BeforeEach
void setUp() {
orderRepository = mock(OrderRepository.class);
paymentGateway = mock(PaymentGateway.class);
eventPublisher = mock(EventPublisher.class);
orderService = new OrderServiceImpl(
orderRepository, paymentGateway, eventPublisher
);
}
@Test
void shouldPlaceOrderSuccessfully() {
// Given
when(paymentGateway.charge(any(), any()))
.thenReturn(PaymentResult.success("pay-123"));
when(orderRepository.save(any())).thenAnswer(i -> i.getArgument(0));
PlaceOrderCommand command = new PlaceOrderCommand(
"customer-1",
List.of(new OrderItemRequest("prod-1", "Widget", 2, new BigDecimal("10.00")))
);
// When
String orderId = orderService.placeOrder(command);
// Then
assertNotNull(orderId);
verify(paymentGateway).charge("customer-1", new BigDecimal("20.00"));
verify(orderRepository).save(any(Order.class));
verify(eventPublisher).publish(any(OrderPlacedEvent.class));
}
@Test
void shouldRejectOrderWhenPaymentFails() {
// Given
when(paymentGateway.charge(any(), any()))
.thenReturn(PaymentResult.failure("Insufficient funds"));
PlaceOrderCommand command = new PlaceOrderCommand(
"customer-1",
List.of(new OrderItemRequest("prod-1", "Widget", 1, new BigDecimal("10.00")))
);
// When/Then
assertThrows(PaymentFailedException.class,
() -> orderService.placeOrder(command));
verify(orderRepository, never()).save(any());
verify(eventPublisher, never()).publish(any());
}
@Test
void shouldNotCancelShippedOrder() {
// Given
Order shippedOrder = createShippedOrder();
when(orderRepository.findById("order-1")).thenReturn(Optional.of(shippedOrder));
// When/Then
assertThrows(IllegalStateException.class,
() -> orderService.cancelOrder("order-1", "Changed my mind"));
}
}These tests run in milliseconds — no Spring context, no database, no Kafka. They test pure business logic.
Integration Testing the Adapters
Test each adapter independently against real infrastructure (using test containers):
// Test the persistence adapter with a real database
@DataJpaTest
@Testcontainers
class OrderPersistenceAdapterTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired private OrderJpaRepository jpaRepository;
private OrderPersistenceAdapter adapter;
@BeforeEach
void setUp() {
adapter = new OrderPersistenceAdapter(jpaRepository);
}
@Test
void shouldSaveAndRetrieveOrder() {
Order order = Order.create("customer-1",
List.of(new OrderItem("prod-1", "Widget", 2, new BigDecimal("10.00"))));
adapter.save(order);
Optional<Order> retrieved = adapter.findById(order.getId());
assertTrue(retrieved.isPresent());
assertEquals("customer-1", retrieved.get().getCustomerId());
assertEquals(OrderStatus.PLACED, retrieved.get().getStatus());
}
}Swapping Infrastructure: The Power of Hexagonal
Here's where hexagonal architecture truly shines. Let's swap PostgreSQL for MongoDB — without touching a single line of business logic.
Step 1: Create a MongoDB Adapter
// adapter/out/persistence/mongo/OrderMongoDocument.java
@Document(collection = "orders")
public class OrderMongoDocument {
@Id private String id;
private String customerId;
private String status;
private BigDecimal totalAmount;
private List<OrderItemDocument> items;
public static OrderMongoDocument fromDomain(Order order) { /* ... */ }
public Order toDomain() { /* ... */ }
}
// adapter/out/persistence/mongo/OrderMongoRepository.java
public interface OrderMongoRepository extends MongoRepository<OrderMongoDocument, String> {
List<OrderMongoDocument> findByCustomerId(String customerId);
}
// adapter/out/persistence/mongo/OrderMongoAdapter.java
@Component
@Profile("mongodb") // Activate with Spring profile
public class OrderMongoAdapter implements OrderRepository {
private final OrderMongoRepository mongoRepository;
@Override
public Order save(Order order) {
mongoRepository.save(OrderMongoDocument.fromDomain(order));
return order;
}
@Override
public Optional<Order> findById(String id) {
return mongoRepository.findById(id).map(OrderMongoDocument::toDomain);
}
@Override
public List<Order> findByCustomerId(String customerId) {
return mongoRepository.findByCustomerId(customerId).stream()
.map(OrderMongoDocument::toDomain).toList();
}
}Step 2: Switch Using Spring Profile
# application-mongodb.yml
spring:
data:
mongodb:
uri: mongodb://localhost:27017/orders# Run with PostgreSQL (default)
java -jar app.jar
# Run with MongoDB
java -jar app.jar --spring.profiles.active=mongodbThat's it. The core (OrderServiceImpl, Order, all business rules) is completely unchanged. Only the adapter was swapped.
Similarly, switching from Kafka to RabbitMQ:
// adapter/out/messaging/RabbitEventPublisher.java
@Component
@Profile("rabbitmq")
public class RabbitEventPublisher implements EventPublisher {
private final RabbitTemplate rabbitTemplate;
@Override
public void publish(DomainEvent event) {
rabbitTemplate.convertAndSend("order-events", event);
}
}Hexagonal vs Layered Architecture
| Aspect | Layered | Hexagonal |
|---|---|---|
| Dependency direction | Top → bottom | Outside → inside |
| Business logic depends on | Data access layer | Nothing (defines ports) |
| Testing core logic | Needs database or mocks of concrete classes | Mock interfaces (ports) |
| Swapping infrastructure | Requires changes in business layer | Only change the adapter |
| Complexity | Simpler, fewer files | More files and interfaces |
| Best for | Simple CRUD apps | Complex business logic, multiple integrations |
When to choose hexagonal over layered:
- Your business logic is the most valuable part of the application
- You need to support multiple infrastructure options
- You want to test business logic in isolation
- Your domain has complex rules that change independently of infrastructure
When layered is sufficient:
- Simple CRUD with thin business logic
- Single database, single API, no messaging
- Small team where the extra abstraction adds cognitive overhead
Common Mistakes
1. Leaking Framework Code Into the Core
// ❌ WRONG — Spring annotations in the core
@Service // Spring!
public class OrderServiceImpl implements OrderService {
@Autowired // Spring!
private OrderRepository orderRepository;
@Transactional // Spring!
public String placeOrder(PlaceOrderCommand command) { ... }
}// ✅ CORRECT — Pure core, Spring only in configuration
public class OrderServiceImpl implements OrderService {
private final OrderRepository orderRepository;
public OrderServiceImpl(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public String placeOrder(PlaceOrderCommand command) { ... }
}
// Configuration class handles Spring wiring
@Configuration
public class OrderConfig {
@Bean
public OrderService orderService(OrderRepository repo) {
return new OrderServiceImpl(repo);
}
}2. Domain Entities as Database Entities
// ❌ WRONG — Domain entity with JPA annotations
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue private Long id;
@Column private String customerId;
// Business logic mixed with persistence concerns
}// ✅ CORRECT — Separate domain entity and JPA entity
// domain/model/Order.java — Pure domain
public class Order {
private String id;
private String customerId;
// Business logic only
}
// adapter/out/persistence/OrderJpaEntity.java — JPA only
@Entity @Table(name = "orders")
public class OrderJpaEntity {
@Id private String id;
@Column private String customerId;
// Mapping logic only
}3. Too Many Ports for Simple Operations
// ❌ Over-engineering — a port for every single operation
public interface OrderSaver { void save(Order order); }
public interface OrderFinder { Optional<Order> findById(String id); }
public interface OrderDeleter { void delete(String id); }
public interface OrderLister { List<Order> findAll(); }// ✅ Pragmatic — one port per driven dependency
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(String id);
List<Order> findByCustomerId(String customerId);
void delete(String id);
}4. Adapters Containing Business Logic
// ❌ WRONG — Business logic in the adapter
@Component
public class OrderPersistenceAdapter implements OrderRepository {
@Override
public Order save(Order order) {
// Business validation in the adapter!
if (order.getTotalAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new ValidationException("Order total must be positive");
}
jpaRepository.save(OrderJpaEntity.fromDomain(order));
return order;
}
}Adapters should only translate between the core's language and the infrastructure's language. All business logic belongs in the core.
Summary
Hexagonal architecture (Ports & Adapters) places business logic at the center of the application, isolated from all external dependencies through well-defined interfaces.
Core concepts:
- The Core — domain model and use cases with zero infrastructure dependencies
- Ports — interfaces defined by the core (what it offers and what it needs)
- Adapters — concrete implementations that connect ports to real infrastructure
Two types of ports:
- Driving (primary) — how the outside world interacts with the app (REST, GraphQL, CLI)
- Driven (secondary) — what the app needs from the outside world (database, payment, messaging)
Key benefits:
- Business logic is testable without any infrastructure
- Swap infrastructure (databases, message brokers, APIs) without changing core logic
- Domain model is framework-agnostic and portable
- Clear separation between "what" (ports) and "how" (adapters)
Key rule:
- All dependencies point inward — the core never references adapters
- Adapters depend on the core (implement core interfaces)
- The core depends only on its own ports (interfaces)
When to use:
- Complex business logic that needs isolation from infrastructure
- Multiple integration points (databases, APIs, message brokers)
- Long-lived applications where infrastructure will change
- Teams that value testability and clean separation
When to skip:
- Simple CRUD applications with minimal business logic
- Small prototypes where speed matters more than structure
- Applications tightly coupled to a single infrastructure by design
The hexagon represents a boundary: inside is what your application is, outside is what it uses. Keep that boundary clean, and your code stays adaptable.
What's Next in the Software Architecture Series
This is post 8 of 12 in the Software Architecture Patterns series:
- ✅ ARCH-1: Software Architecture Patterns Roadmap
- ✅ ARCH-2: Monolithic Architecture
- ✅ ARCH-3: Layered (N-Tier) Architecture
- ✅ ARCH-4: MVC, MVP & MVVM Patterns
- ✅ ARCH-5: Microservices Architecture
- ✅ ARCH-6: Event-Driven Architecture
- ✅ ARCH-7: CQRS & Event Sourcing
- ✅ ARCH-8: Hexagonal Architecture (this post)
- 🔜 ARCH-9: Clean Architecture
- 🔜 ARCH-10: Domain-Driven Design (DDD)
- 🔜 ARCH-11: Serverless & Function-as-a-Service
- 🔜 ARCH-12: Choosing the Right Architecture
Related posts:
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.