In modern enterprise software engineering, building applications that survive technology shifts is a constant challenge. Frameworks evolve, database drivers change, and cloud infrastructure moves rapidly. If your core business logic is tightly coupled to Spring annotations, Hibernate entities, or REST controllers, updating infrastructure becomes risky, expensive, and slow.
To solve this fundamental coupling challenge, software engineers created three influential architectural styles: Hexagonal Architecture (Ports and Adapters), Onion Architecture, and Clean Architecture. While each model introduces distinct vocabulary and diagrammatic conventions, they all share a singular goal: isolating the business domain from technical mechanisms. In this comprehensive guide, we compare all three architectures, examine their common foundation, analyze practical code in Spring Boot 4 and Java 25, and provide an objective decision framework for enterprise systems.
Table of Contents
- The Core Dilemma: Why Traditional Layered Architecture Fails
- Hexagonal Architecture (Ports and Adapters) Deep Dive
- Clean Architecture: Concentric Circles and the Dependency Rule
- Onion Architecture: Domain Services and Concentric Isolation
- Side-by-Side Structural and Terminology Comparison
- The Unified Dependency Inversion Rule
- Practical Implementation in Spring Boot 4 and Java 25
- Comparative Analysis: Architectural Strategies Matrix
- Real-World Enterprise Scenarios
- Architectural Guardrails and ArchUnit Enforcement
- Common Pitfalls and How to Fix Them
- Testing Strategies: Unit Isolation vs. Slice Tests
- Frequently Asked Questions
- Conclusion and Next Steps
- References
The Core Dilemma: Why Traditional Layered Architecture Fails
For more than two decades, the standard pattern for Java web development was the three-tier layered architecture: Controller Layer, Service Layer, and Data Access / Repository Layer. In this traditional model, dependencies point downwards:
Controller -> Service -> Repository -> Database
While intuitive, this linear dependency chain creates serious long-term architectural decay in enterprise projects:
- Database-Driven Design: Because the Service layer directly references the Repository layer, database concerns dictate domain modeling. Developers design relational tables and Hibernate entities first, then build business logic around database schemas rather than pure business rules.
- Pervasive Framework Contamination: JPA annotations (such as
@Entity,@Table,@JoinColumn) and Jackson annotations leak directly into domain objects. The business logic cannot run without Spring, Hibernate, and an active database connection. - Brittle Unit Testing: Testing business logic requires initializing Spring ApplicationContext, configuring in-memory databases (H2), or managing complex Mockito mock chains. Tests become slow, difficult to maintain, and fragile.
- High Refactoring Costs: Replacing a relational database with a document store, splitting services into microservices, or adopting an asynchronous messaging broker (like Apache Kafka) requires rewriting core business classes across every layer.
Hexagonal, Clean, and Onion architectures dismantle this top-down dependency chain by applying the Dependency Inversion Principle (DIP): high-level business policies must never depend on low-level technical details; both must depend on abstractions.
Hexagonal Architecture (Ports and Adapters) Deep Dive
Formulated by Alistair Cockburn in 2005, Hexagonal Architecture (formally known as the Ports and Adapters pattern) was designed to solve a specific problem: applications could only interact through their graphical user interface and database, making automated testing difficult.

The hexagon shape is symbolic: it does not imply six sides or six components. Cockburn chose a polygon to visually represent multiple connection points (ports) through which the application interacts with the external world.
Core Concepts in Hexagonal Architecture
- The Application Core (Inside): Contains pure domain entities, business rules, and use case services. The inside has zero dependencies on any external framework, database, or UI technology.
- Ports (The Boundary): A port is a language-level interface that defines a protocol of interaction. Ports belong entirely to the application core.
- Inbound (Driving / Primary) Ports: Interfaces that define what the application can do (e.g.,
CreateUserUseCase,SubmitOrderCommand). Driving actors (REST controllers, CLI commands, Kafka consumers) call these ports. - Outbound (Driven / Secondary) Ports: Interfaces that define what the application needs from external systems (e.g.,
UserRepositoryPort,PaymentGatewayPort,NotificationPort).
- Inbound (Driving / Primary) Ports: Interfaces that define what the application can do (e.g.,
- Adapters (Outside): Concrete implementations of ports that translate external technologies into domain concepts and vice versa.
- Inbound Adapters: Spring Web MVC controllers, GraphQL resolvers, or queue listeners that receive HTTP requests, parse JSON payloads, and invoke an inbound port.
- Outbound Adapters: Classes that implement outbound ports (e.g.,
JpaUserRepositoryAdapterimplementingUserRepositoryPortusing Spring Data JPA).
Clean Architecture: Concentric Circles and the Dependency Rule
Published by Robert C. Martin (“Uncle Bob”) in 2012, Clean Architecture consolidated Hexagonal Architecture, Onion Architecture, and DCI (Data, Context, and Interaction) into a unified visual model structured around concentric circular layers.

The foundational premise of Clean Architecture is The Dependency Rule: Source code dependencies must point only inward, toward higher-level policies. Nothing in an inner circle can know anything at all about something in an outer circle. This includes functions, classes, variables, and data formats.
The Four Concentric Circles of Clean Architecture
- Entities (Yellow Core): Enterprise-wide business rules. Entities encapsulate critical business data and business invariants. They can be plain Java objects, records, or rich domain classes. They are completely independent of UI, database, or application frameworks.
- Use Cases (Red Ring): Application-specific business rules. Use cases coordinate the flow of data to and from entities, directing them to use their enterprise business rules to achieve the goals of the use case (e.g.,
PlaceOrderUseCase). - Interface Adapters (Green Ring): Translators and presenters. This layer converts data from the format most convenient for the use cases and entities into the format most convenient for external agencies (such as databases or the web). Controllers, presenters, and gateways reside here.
- Frameworks and Drivers (Blue Outer Rim): The outermost layer composed of tools, databases, web frameworks (like Spring Boot), message queues, and devices. This layer contains glue code that connects external technical tools to the interface adapters.
Onion Architecture: Domain Services and Concentric Isolation
Introduced by Jeffrey Palermo in 2008, Onion Architecture was created specifically to counter the database-centric coupling of traditional layered enterprise systems in object-oriented applications.

Onion Architecture explicitly distinguishes between Domain Services (business logic that operates across multiple entities within the domain) and Application Services (coordinating workflow, transactions, security, and use case execution).
Layers of the Onion
- Domain Model: At the very center of the onion. Contains stateful domain entities, value objects, and business rules. It has zero external dependencies.
- Domain Services: Sits directly around the Domain Model. Houses domain-specific logic that naturally involves multiple domain entities or requires complex business calculations that do not belong inside a single entity.
- Application Services: Surrounds the Domain Services. Coordinates application use cases, security contexts, transaction boundaries, and logging. It declares interfaces for external infrastructure (such as repository interfaces and mail sender interfaces).
- Infrastructure / Outer Ring: Contains implementations of interfaces declared in inner rings: database repositories, web API controllers, cloud file storage adapters, and external microservice clients.
Side-by-Side Structural and Terminology Comparison
Engineers often debate whether their project is “Hexagonal,” “Clean,” or “Onion.” In practice, these architectures are semantic variations of the same architectural philosophy. They use different vocabulary to describe identical boundary concepts.

The following mapping table translates the terminology across all three styles:
| Architectural Role | Hexagonal (Cockburn) | Clean Architecture (Martin) | Onion Architecture (Palermo) |
|---|---|---|---|
| Core Business Rules | Domain / Model | Enterprise Business Rules (Entities) | Domain Model Core |
| Cross-Entity Business Logic | Domain Services | Domain Services | Domain Services Layer |
| Application Use Cases | Application / Inside Service | Use Cases Layer | Application Services Layer |
| Inbound Contract | Inbound / Driving Port | Use Case Input Port | Application Service Interface |
| Outbound Contract | Outbound / Driven Port | Gateway / Output Port Interface | Repository / Infrastructure Interface |
| Inbound Trigger | Driving Adapter (Controller) | Interface Adapter (Controller) | UI / API Controller (Outer Ring) |
| Outbound Implementation | Driven Adapter (JPA Repo) | Frameworks / DB Adapter | Infrastructure Implementation |
The Unified Dependency Inversion Rule
Despite their aesthetic differences—Cockburn’s hexagon, Martin’s concentric colored rings, and Palermo’s onion layers—all three architectures enforce the exact same universal law of software design: The Unified Dependency Inversion Rule.

This universal architectural synthesis establishes three invariant principles:
- The Domain is Sovereign: Business rules have zero dependencies on frameworks, databases, or communication protocols. If Spring Boot or PostgreSQL ceased to exist tomorrow, your domain model code would compile without modification.
- Dependencies Point Inward: Outer layers depend on inner layers. Inner layers never depend on outer layers. Code in the domain layer must never import packages from infrastructure, controllers, or database frameworks.
- Abstractions Are Owned by the Caller: When the core domain requires external services (such as persisting an entity or sending an email), it defines an interface (a Port or SPI) in its own package. The infrastructure layer implements that interface. Control flows outward at runtime, but source code dependencies point strictly inward.
Practical Implementation in Spring Boot 4 and Java 25
Our companion workshop project demonstrates all three architectures within a clean Spring Boot 4 and Java 25 environment. Below, we examine concrete implementations of each pattern.
1. Hexagonal Architecture Implementation (Ports & Adapters)
In Hexagonal Architecture, the domain defines the User entity and the UserRepositoryPort. The use case implements the CreateUserUseCase inbound port:
// Inbound Port: Application contract for creating a user
package com.webcodein.architecture.hexagonal.port.in;
import com.webcodein.architecture.hexagonal.domain.User;
public interface CreateUserUseCase {
User createUser(String id, String name);
}
// Outbound Port: Secondary SPI defined by the domain
package com.webcodein.architecture.hexagonal.port.out;
import com.webcodein.architecture.hexagonal.domain.User;
public interface UserRepositoryPort {
void save(User user);
User findById(String id);
}
// Driving Adapter: Spring MVC Controller invoking Inbound Port
package com.webcodein.architecture.hexagonal.adapter.in;
import com.webcodein.architecture.hexagonal.domain.User;
import com.webcodein.architecture.hexagonal.port.in.CreateUserUseCase;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/hex/users")
public class UserController {
private final CreateUserUseCase createUserUseCase;
public UserController(CreateUserUseCase createUserUseCase) {
this.createUserUseCase = createUserUseCase;
}
@PostMapping
public ResponseEntity<User> create(@RequestBody User user) {
User created = createUserUseCase.createUser(user.getId(), user.getName());
return ResponseEntity.ok(created);
}
}
2. Clean Architecture Implementation (Use Case Interactors)
Clean Architecture organizes workflows into Use Case Interactors that accept request models and return response models:
// Use Case Interactor coordinating entity business rules
package com.webcodein.architecture.clean.usecase;
import com.webcodein.architecture.clean.domain.Order;
public class CreateOrderUseCase {
private final OrderRepositoryGateway repositoryGateway;
public CreateOrderUseCase(OrderRepositoryGateway repositoryGateway) {
this.repositoryGateway = repositoryGateway;
}
public Order execute(String id) {
Order order = new Order(id, "CREATED");
repositoryGateway.save(order);
return order;
}
}
3. Onion Architecture Implementation (Core Domain Services)
Onion Architecture emphasizes the domain service operating over core entities:
// Core Domain Service in Onion Architecture
package com.webcodein.architecture.onion.domain;
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
public Product createProduct(String id) {
Product product = new Product(id, "ACTIVE");
repository.save(product);
return product;
}
}
Comparative Analysis: Architectural Strategies Matrix
To help software architects select the right architectural strategy for their systems, the following matrix compares six architectural patterns across key enterprise metrics:
| Architecture Style | Boilerplate Overhead | Domain Isolation | Refactoring Agility | Testability Velocity | Ideal Enterprise Workload |
|---|---|---|---|---|---|
| Traditional 3-Tier Layered | Very Low (Direct mapping) | Low (DB coupled) | Low (Cascading breaks) | Moderate (Requires Spring mocks) | Simple CRUD microservices, rapid internal prototypes, throwaway proofs of concept. |
| Hexagonal (Ports & Adapters) | Moderate (Ports & Adapters) | High (Pure core) | Very High (Interchangeable adapters) | Very High (Zero framework mocks) | Services with multiple protocols (HTTP REST, gRPC, Kafka consumers, CLI tools). |
| Onion Architecture | Moderate (Layer separation) | High (Domain service focus) | High (Infrastructure swappable) | High (Isolated domain logic) | Domain-heavy enterprise systems with complex calculation engines and rich entity state. |
| Clean Architecture | High (Interactors & Presenters) | Very High (Concentric rings) | Very High (Complete UI/DB decoupling) | Very High (Pure unit tests) | Long-lived enterprise platforms with high regulatory compliance and multi-team collaboration. |
| Modular Monolith (Spring Modulith) | Low (Package boundaries) | High (Domain events) | High (Monolith-to-microservice path) | Very High (Slice integration tests) | Medium-to-large business applications looking to avoid distributed system operational cost. |
| Event-Driven CQRS (Axon / Kafka) | High (Command/Query split) | Very High (Immutable events) | High (Independent read scaling) | Moderate (Async event replay) | Financial transaction ledgers, audit systems, inventory order fulfillment platforms. |
Real-World Enterprise Scenarios
To understand when each architectural approach excels, consider three real-world enterprise deployments:
1. Core Banking Financial Ledger (Hexagonal Architecture)
A retail bank must process payments originating from diverse channels: SWIFT financial messages, mobile banking REST APIs, ATM ISO-8583 connections, and nightly batch processing files. The core ledger business rules (double-entry bookkeeping, account debit limits, currency rounding invariants) must execute identically regardless of whether the transaction arrived via an ATM socket or a REST call.
Why Hexagonal Excels: The core accounting domain defines a single Inbound Port: PostTransactionUseCase. Multiple driving adapters (REST Controller, ISO-8583 Socket Listener, SWIFT MQ Consumer) convert incoming payloads into standard domain commands. Outbound ports decouple the domain from Oracle, PostgreSQL, or audit Kafka streams.
2. Multi-Cloud E-Commerce Order Platform (Clean Architecture)
A global retailer operates in hybrid cloud environments across AWS, Azure, and on-premises data centers. To avoid cloud vendor lock-in, the engineering management mandates that order placement, inventory reservations, and tax calculations must be completely independent of AWS DynamoDB, Azure Cosmos DB, or specific cloud messaging systems.
Why Clean Architecture Excels: Clean Architecture enforces rigorous use-case boundaries. The PlaceOrderUseCase interactor accepts input data transfer objects, interacts with pure domain entities, and outputs through presenter interfaces. The persistence layer implements cloud-specific database adapters without leaking cloud SDK annotations into business code.
3. Healthcare Clinical Patient Records Gateway (Onion Architecture)
A medical healthcare software system manages electronic health records (EHR) across hospitals. Clinical dosage calculations and patient allergy contraindications involve complex business rules across multiple domain entities (Patient, Medication, Diagnosis, LabResult).
Why Onion Architecture Excels: Onion Architecture’s dedicated Domain Services layer provides a natural home for cross-entity clinical validation rules that do not belong inside an individual Patient or Medication entity. Application services handle hospital audit logging and HIPAA compliance encryption without compromising domain logic.
Architectural Guardrails and ArchUnit Enforcement
The greatest risk to decoupled architectures is human error: a developer accidentally imports a Spring framework class or a JPA entity inside the domain package. Over time, architectural boundaries erode until the codebase degrades back into a tangled monolith.
Automated architectural fitness tests using ArchUnit prevent this erosion by failing the build whenever a boundary rule is violated. Below is an essential ArchUnit test verifying Hexagonal and Clean Architecture rules:
package com.webcodein.architecture;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
@AnalyzeClasses(packages = "com.webcodein.architecture", importOptions = ImportOption.DoNotIncludeTests.class)
public class ArchitectureBoundaryRulesTest {
@ArchTest
public static final ArchRule domainMustNotDependOnFrameworks =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework..",
"jakarta.persistence..",
"jakarta.servlet..",
"com.fasterxml.jackson.."
)
.because("The domain model must remain purely decoupled from external frameworks and persistence details.");
@ArchTest
public static final ArchRule domainMustNotDependOnAdapters =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..adapter..")
.because("Source code dependencies must point inward; domain classes must never reference adapters.");
@ArchTest
public static final ArchRule adaptersMustImplementPorts =
classes().that().resideInAPackage("..adapter.out..")
.should().implement(classes().that().resideInAPackage("..port.out.."))
.because("Outbound secondary adapters must implement domain-defined outbound ports.");
}
Running this test during Maven builds guarantees that architectural boundaries remain strictly enforced across pull requests and team changes.
Common Pitfalls and How to Fix Them
When migrating or implementing decoupled architectures, development teams frequently fall victim to three anti-patterns:
Pitfall 1: Leaking Persistence Annotations into the Domain
Symptom: Developers place JPA annotations (@Entity, @Id, @OneToMany) directly onto domain classes to avoid writing separate database entities and mappers. Over time, JPA lifecycle quirks (lazy loading exceptions, dirty checking, cascading deletes) contaminate business logic.
Solution: Strictly separate Domain Entities from Persistence Entities. Maintain a pure domain class in the domain package, and a separate UserJpaEntity in the outbound adapter package. Use an explicit mapper or MapStruct:
// Pure domain entity: No JPA annotations, full business validation
public class User {
private final UserId id;
private final String name;
public User(UserId id, String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("User name must not be blank.");
}
this.id = id;
this.name = name;
}
// Business methods...
}
Pitfall 2: Over-Abstraction with Useless Pass-Through Layers
Symptom: For a simple read operation, a developer creates an Inbound Port, a Use Case Interactor, an Outbound Port, an Adapter, and a Repository, with each layer doing nothing more than calling the next layer without business logic.
Solution: Apply CQRS (Command Query Responsibility Segregation). For complex write operations with business invariants, use full Ports and Adapters. For simple read queries that only fetch data for the UI, bypass use case interactors and let read adapters query the database directly using projections.
Pitfall 3: The Anemic Domain Model Anti-Pattern
Symptom: Domain entities become dumb data holders containing only getters and setters, while all business logic resides in huge procedural Service or Use Case classes.
Solution: Keep domain logic inside the domain entities. Entities must enforce their own business invariants, state transitions, and validation rules. The use case interactor should only orchestrate the loading of the entity, invoking its business methods, and saving the updated entity via the repository port.
Testing Strategies: Unit Isolation vs. Slice Tests
One of the greatest advantages of Hexagonal, Clean, and Onion architectures is testing velocity. In traditional layered architectures, developers are forced to write heavy @SpringBootTest integration tests for simple logic. In decoupled architectures, testing aligns with the pyramid:
- Pure Unit Tests for Domain Entities: Test core business rules in milliseconds using standard JUnit 5. No Spring context, no database, no mocks.
- Use Case Tests with In-Memory Mocks: Test use case interactors using simple in-memory repository implementations (e.g., a
HashMapbacked port implementation). This tests the entire application workflow in under 10 milliseconds without Spring. - Adapter Slice Tests:
- Test Inbound Adapters (REST Controllers) using Spring Boot’s lightweight
@WebMvcTest. - Test Outbound Adapters (JPA Repositories) using real PostgreSQL Testcontainers via
@DataJpaTest.
- Test Inbound Adapters (REST Controllers) using Spring Boot’s lightweight
Frequently Asked Questions
Which architecture should I choose for a new Spring Boot 4 project?
For most enterprise applications, Hexagonal Architecture (Ports and Adapters) offers the most pragmatic balance between architectural purity and development speed. Its distinction between driving and driven ports maps cleanly to Spring Boot controllers and repository beans without the excessive presenter layers found in Clean Architecture.
Do I need separate DTOs for every layer?
Yes, at architectural boundaries. Inbound adapters should map HTTP JSON requests to domain command objects, and outbound adapters should map domain entities to database records. Sharing a single entity across all layers reintroduces the tight coupling that these architectures were specifically designed to prevent.
Does Hexagonal Architecture impact runtime performance?
No. The mapping between DTOs and domain objects takes nanoseconds in memory. Furthermore, because domain logic is completely isolated from the database, you can optimize caching, connection pooling, and virtual thread concurrency in the adapter layer without touching business logic.
Can I combine Hexagonal Architecture with Domain-Driven Design (DDD)?
Absolutely. Hexagonal Architecture is the primary architectural style recommended by Domain-Driven Design practitioners. DDD provides the tactical patterns (Entities, Value Objects, Aggregates, Domain Events) to model the inside of the hexagon, while Ports and Adapters provide the outer delivery boundaries.
How does Spring Modulith relate to Clean or Hexagonal Architecture?
Spring Modulith enforces modular boundaries at the aggregate or module level within a Spring Boot monolith. You can structure individual modules internally using Hexagonal Architecture (Ports and Adapters), while using Spring Modulith to enforce communication between modules via asynchronous domain events.
Conclusion and Next Steps
Hexagonal Architecture, Clean Architecture, and Onion Architecture are different expressions of the same timeless engineering wisdom: protect the core business domain from volatile technical dependencies.
By placing your business rules at the center, defining strict inbound and outbound ports, and inverting source code dependencies, you build applications that easily withstand framework upgrades, database migrations, and operational shifts. Combined with Spring Boot 4 and Java 25, these architectures provide the foundation for robust, verifiable, and long-lasting enterprise software.
To deepen your software architecture and modern Java skills, explore our related engineering deep dives across the WebCodein ecosystem:
- Refactoring a Legacy Spring Boot Monolith to Hexagonal Architecture
- Clean Architecture & Hexagonal Ports and Adapters in Spring Boot 4
- Enforcing Hexagonal Architecture Boundaries with ArchUnit
- Spring Modulith: Building Resilient Modular Monoliths with Spring Boot 4
- TDD with Spring Boot 4 and Testcontainers: Zero-Mocking Architecture
- Java 25 Virtual Threads: Production Pitfalls and Best Practices for Spring Boot
You can find the complete, runnable Spring Boot 4 companion project for this guide on GitHub:
GitHub: WebCodein Workshop — Hexagonal vs Clean vs Onion Architecture
