Advanced · Architect
Choose Layered, Hexagonal or Clean Boundaries
Compare dependency direction, testing seams, ceremony, and change cost around one real use case.
Primary source: Spring Framework 6.2 Core Technologies: The IoC Container
Lesson 1 of 3
1d streakThe Architectural Question
Hexagon ceremony wrapped around plain CRUD adds ports nobody will ever swap; weak boundaries around a rules-heavy domain leak persistence into every decision.
What this guide proves
- Keep domain policy independent of delivery and persistence details
- Use ports at boundaries with meaningful replacement or testing value
- Allow simple CRUD paths to remain simple
Hexagonal Architecture: Ports and Adapters in Spring Boot
Hexagonal Architecture (Ports and Adapters) organizes an enterprise application so that core business domain logic is decoupled from frameworks, databases, messaging brokers, and UI protocols. Rather than business logic depending on database tables or Spring MVC controllers, the Dependency Inversion Principle (DIP) is strictly applied.
The architecture is divided into three distinct concentric rings:
1. Domain Core: Pure business entities, aggregates, domain events, and value objects. ZERO framework dependencies.
2. Inbound & Outbound Ports: Java interfaces owned by the application. Inbound ports (use cases) define what the application does; Outbound ports define what external capabilities (persistence, payment, notifications) the application requires.
3. Adapters: Concrete implementations of ports. Web controllers are Inbound Adapters translating HTTP to use-case commands; Spring Data JPA repositories are Outbound Adapters fulfilling persistence ports.
package com.example.order.domain.port.in;
// Inbound Port (Use Case) owned by application core
public interface PlaceOrderUseCase {
OrderId placeOrder(PlaceOrderCommand command);
}
// Outbound Port (Driven SPI) owned by application core
package com.example.order.domain.port.out;
import java.util.Optional;
public interface OrderRepositoryPort {
Order save(Order order);
Optional<Order> findById(OrderId id);
}
public interface PaymentGatewayPort {
PaymentReceipt charge(CustomerId customerId, Money amount);
}Who Owns the Interfaces?
Pure Domain Models: Banishing @Entity and Framework Imports
A common antipattern in Spring development is decorating rich domain entities with @Entity, @Table, @Column, @JsonIgnore, and @NotNull. This tightly couples your core business logic to Hibernate proxy semantics, JPA dirty checking, and no-arg constructors.
Use it when
Keep domain entities as pure Java classes or records with rich business invariant validation in their constructors. Create dedicated JPA Entity classes in the persistence adapter package specifically for database mapping.
Reconsider when
Accept the minor cost of object mapping at the boundary in exchange for absolute domain testability, immutability, and framework independence.
package com.example.order.domain.model;
import java.time.Instant;
import java.util.List;
// Pure Java Domain Aggregate: NO JPA, NO Spring, NO Jackson annotations
public final class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderLine> lines;
private OrderStatus status;
private final Instant createdAt;
public Order(OrderId id, CustomerId customerId, List<OrderLine> lines) {
if (lines == null || lines.isEmpty()) {
throw new IllegalArgumentException("Order must contain at least one line item");
}
this.id = id;
this.customerId = customerId;
this.lines = List.copyOf(lines);
this.status = OrderStatus.PENDING_PAYMENT;
this.createdAt = Instant.now();
}
public void markPaid(PaymentReceipt receipt) {
if (this.status != OrderStatus.PENDING_PAYMENT) {
throw new IllegalStateException("Cannot pay an order in status: " + this.status);
}
this.status = OrderStatus.PAID;
}
public OrderId id() { return id; }
public OrderStatus status() { return status; }
}Boundary Mapping and Translation Adapters
Data crossing architectural boundaries must be explicitly translated. An HTTP JSON request maps to a Command object; the Command hydrates a Domain Aggregate; the Domain Aggregate translates into a JPA entity for persistence.
package com.example.order.adapter.out.persistence;
import com.example.order.domain.model.Order;
import com.example.order.domain.port.out.OrderRepositoryPort;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
public class OrderPersistenceAdapter implements OrderRepositoryPort {
private final SpringDataJpaOrderRepository jpaRepository;
private final OrderMapper mapper;
public OrderPersistenceAdapter(SpringDataJpaOrderRepository jpaRepository, OrderMapper mapper) {
this.jpaRepository = jpaRepository;
this.mapper = mapper;
}
@Override
public Order save(Order order) {
OrderJpaEntity entity = mapper.toJpaEntity(order);
OrderJpaEntity saved = jpaRepository.save(entity);
return mapper.toDomain(saved);
}
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.value()).map(mapper::toDomain);
}
}Automated Architectural Boundary Testing with ArchUnit
Enforce hexagonal boundaries automatically in your CI pipeline using ArchUnit to fail the build if a domain class accidentally imports Spring, Hibernate, or Jackson:
package com.example.order;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
class HexagonalArchitectureTest {
private final JavaClasses classes = new ClassFileImporter().importPackages("com.example.order");
@Test
void domainMustNotDependOnExternalFrameworksOrAdapters() {
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("..adapter..", "org.springframework..", "jakarta.persistence..")
.check(classes);
}
}Active Recall Checkpoint
What is the primary benefit of testing pure domain classes without Spring or database annotations?
Hint: Think about test suite execution speed and clean separation of concerns.
Show answer & explanation
Domain tests run as lightning-fast plain JUnit tests (microseconds per test) with zero application context startup time, and business rules remain immune to database or framework upgrades.
Runtime Architecture & Flow
Dependency direction
Boundaries follow ownership
Architecture is the set of boundaries that keeps important decisions easy to test and expensive details replaceable.
Keep domain policy independent of delivery and persistence details
B1Use ports at boundaries with meaningful replacement or testing value
B2Allow simple CRUD paths to remain simple
B3Read these as design constraints. The arrows belong in the implementation only after the dependency rule is chosen.
Synthesis & Production Verification
Deliberate Failure Condition
- Break: Wrap simple CRUD in four layers of one-to-one pass-through mappers
- Observe: Every new field edits six files and reviewers rubber-stamp changes nobody understands
- Repair: Boundary weight should track domain complexity; a port earns its place by offering real substitution or testing value.
Production Acceptance Criteria
- Keep dependency direction pointing inward on the checkout slice
- Swap payment and inventory adapters for fakes in tests through ports
- Leave simple CRUD paths direct instead of ceremonial
Key Takeaway
Hexagonal architecture inverts dependencies so pure domain models depend on nothing outside domain; adapters implement application-owned ports.
Architectural & Technical Review
Core Interview Questions
Q01What is the difference between Inbound (Driving) and Outbound (Driven) ports in Hexagonal Architecture?Show answer
Inbound ports are use-case interfaces called by drivers (e.g. Web controllers or CLI). Outbound ports are SPI interfaces owned by the application core and implemented by infrastructure adapters (e.g. JPA repositories or payment clients).
Q02Why should core business domain entities remain completely free of @Entity and JPA annotations?Show answer
Coupling domain classes to JPA forces compromise on constructors, immutability, and proxy mechanics. Pure domain records/classes can be unit tested in microseconds with zero framework dependencies and remain insulated from schema migrations.
Q03How can automated ArchUnit tests enforce that the domain layer never imports Spring or persistence packages?Show answer
ArchUnit tests inspect compiled bytecode in CI, verifying rules like noClasses().that().resideInAPackage("..domain..").should().dependOnClassesThat().resideInAnyPackage("..adapter..", "org.springframework..") to halt bad commits.
Production Pitfalls
Critical Anti-Patterns
- Pitfall: Coupling domain entities directly to JPA annotations, Hibernate proxies, and no-arg constructors — Contaminates pure business rules with persistence mechanics and dirty-checking quirks.
- Pitfall: Allowing web controllers to call persistence repositories directly, bypassing use-case ports — Bypasses domain validation, business invariants, and application transaction boundaries.
Primary Documentation & Specifications
Lesson Primary References
Supplementary Module Resources