A Culinary Warning and the Dark Side of Code
Welcome, fellow architect and beloved developer, to our hexagonal city-state—a realm where pragmatism reigns supreme and flexibility is our greatest strength. This guide is not an ivory tower doctrine filled with theoretical perfection, but rather a versatile toolbox tempered in the forge of real-world challenges and countless production battlefields.
Before we can erect the citadel of clean architecture, we must first acknowledge the reality of our industry. We must navigate the “Dark Side of Code”. The software world is rife with culinary disasters that would make a chef weep. We are all too familiar with Spaghetti Code1, a tangled mess where program flow twists and turns like pasta, making maintenance a nightmare where references and dependencies jump all over the place. But the menu of disaster is extensive.
We encounter Spaghetti with Meatballs2, a chaotic fusion where object-oriented “meatballs” float aimlessly in a sea of procedural spaghetti, often the result of a lack of coding standards or language constraints. We face Ravioli Code3, where the pursuit of modularity creates thousands of tiny, loosely coupled components that become impossible to track and reason about. And then there is Lasagna Code4, the layered architecture gone wrong, where rigid tiers create a monolithic structure so fragile that a change in the database foundation ripples all the way up to the UI roof.
Beyond pasta, we battle behavioural anti-patterns. “Hands in the Pants”5 represents a violation of encapsulation, where developers bypass interfaces to grope the internal workings of a component. This often manifests as Anemic Domain Models6—objects that are merely bags of getters and setters without behaviour.
Consider this Anemic Model, a classic example of the “Hands in the Pants” anti-pattern:
// Anemic domain model
public class Order {
private List<Item> items;
private double totalPrice;
public List<Item> getItems() { return items; }
public void setItems(final List<Item> items) { this.items = items; }
// ... getters and setters for totalPrice
}
// Usage: External logic manipulating internal state
Order order = new Order();
order.setItems(itemList);
double total = 0;
for (Item item : order.getItems()) {
total += item.getPrice();
}
order.setTotalPrice(total);
In this scenario, external code is reaching into the Order object to manipulate its state. To fix this, we must move toward a rich, hexagonal domain where we encapsulate behaviour:
public class Order {
private List<Item> items;
public void addItem(final Item item) {
items.add(item);
}
public double calculateTotalPrice() {
return items.stream().mapToDouble(Item::getPrice).sum();
}
}
We also face “The One Liner”7, where code is compressed into unreadable puzzles, and “Happy Path-Driven Development”8, where we optimistically ignore edge cases until production crashes. To escape this culinary hellscape and these pervasive code smells, we turn to the Hexagon.
Unveiling the Hexagon
The Hexagonal Architecture, also known as “Ports and Adapters,” is not just a pattern; it is a structural framework for achieving Dependency Inversion9. It shares principles with Clean Architecture and Onion Architecture, emphasizing independence from frameworks, UIs, and databases. The core principle is simple yet profound: Dependencies always point inward10.
The Spatial Perspective: Mapping the City-State
To truly understand this architecture, we must visualize it spatially, traveling from the center outwards:
- The Center (The Domain): At the very heart lies the Domain, containing our Entities and Business Rules. This is the “what” and “why” of our application. It knows nothing of the outside world—no databases, no web requests, no frameworks.
- The Inner Ring (Application Services): Surrounding the domain are the Application Services. These represent (implement) the Use Cases. They act as orchestrators, coordinating the flow of data and operations between the outside world and the domain core.
- The Edges (Ports): The boundaries of our hexagon are defined by the Ports.
- Input Ports (Left side) define the contracts for how the world talks to us (Use Cases).
- Output Ports (Right side) define the contracts for what the application needs from the world (e.g., APIs, persistence, notifications).
- Beyond the Hexagon (Adapters): Outside the walls lie the Adapters, the implementation details that bridge the gap to reality.
- Primary (Driving) Adapters (e.g., REST Controllers) invoke input ports to drive the application.
- Secondary (Driven) Adapters (e.g., JPA Repositories, SMTP clients) implement output ports to be driven by the application.
This structure ensures that the core remains isolated. We can swap a database or a UI framework without touching the business logic, achieving true modularity and testability.
Meet the Citizens (From Concept to Code)
Now that we have the blueprint, let us meet the citizens who inhabit our hexagonal city-state.
Input Ports: Gateways to the Core
Input Ports serve as the contract for our Use Cases. They define the specific interactions available to the outside world. A Use Case typically accepts a Command11 (for state-changing operations) or a Query (for data retrieval).
Crucially, these Commands and Queries should be self-validating POJOs that carry data, but no behavior. They represent the intent of the user.
Here is an example of a PlaceOrderCommand that enforces its own integrity upon creation:
public class PlaceOrderCommand {
private final String customerId;
private final List<OrderItemDTO> items;
public PlaceOrderCommand(final String customerId, final List<OrderItemDTO> items) {
this.customerId = Objects.requireNonNull(customerId, "Customer ID must not be null");
this.items = Objects.requireNonNull(items, "Order items must not be null");
if (items.isEmpty()) {
throw new IllegalArgumentException("Order must contain at least one item");
}
}
// Getters...
}
To further decouple the execution of these commands, we can employ a Command Bus12. Instead of injecting a specific service into a controller, the controller sends a command to a bus, which routes it to the appropriate handler. This pattern separates the intent (the Command) from the execution (the Handler).
The Application Layer: Orchestrating Business Processes
Application Services are the conductors of our architectural orchestra. They sit in the Application Layer and bridge the external actors and the internal domain. Their primary responsibility is Orchestration: they coordinate the flow of data, managing interactions between domain objects and output ports. They do not contain business rules; that is the domain’s job.
However, real-world use cases often involve “secondary tasks” that accompany a primary operation. For instance, after signing up a user (primary task), we might need to send a confirmation email or update statistics. Implementing these directly in the service violates the Single Responsibility Principle13.
The solution is Domain Events. By dispatching an event after the primary task, we can delegate secondary side effects to separate handlers.
Here is a refactored SignupApplicationService using Domain Events:
public class SignupApplicationService implements SignupUseCase {
private final UserRepository userRepository;
private final DomainEventDispatcher domainEventDispatcher;
public SignupApplicationService(final UserRepository userRepository, final DomainEventDispatcher domainEventDispatcher) {
this.userRepository = userRepository;
this.domainEventDispatcher = domainEventDispatcher;
}
@Override
public void signup(final SignupCommand command) {
// 1. Check business rules (e.g., email uniqueness)
if (this.userRepository.existsByEmail(command.getEmail())) {
throw new IllegalArgumentException("Email already in use");
}
// 2. Create the user (Primary Task)
final User user = new User(command.getName(), command.getEmail());
this.userRepository.save(user);
// 3. Publish Domain Event to delegate 'secondary tasks'
this.domainEventDispatcher.dispatch(new UserSignedUpEvent(user));
}
}
Now, separate handlers can listen for UserSignedUpEvent to send emails or update profiles, keeping the service pure and adhering to the Open/Closed Principle14.
The Domain Layer: The Heart of the Hexagon
We arrive at the core: the Domain Layer. Here, we must shift from an Anemic Model to a Rich Domain Model. Our domain must adhere to three functional principles: Purity (no side effects), Encapsulation (hiding internal state), and Completeness (handling all scenarios).
The domain is populated by specific building blocks:
- Entities & Value Objects: We should use Value Objects15 over primitives to prevent “Primitive Obsession”.
- Domain Services: When logic spans multiple aggregates (e.g., calculating a chess rating based on two players), use a Domain Service16 rather than forcing the logic into one entity.
- Factories: Use Factories to handle complex object creation, ensuring that objects are never instantiated in an invalid state. They are the guardians of domain integrity at the point of creation.
Output Ports: The Diplomatic Corps
Output Ports are the channels through which our city-state communicates with the outside world. They are Role Interfaces17, defined by looking at the specific interaction required by the domain, not by the implementation details of the external tool.
A common pitfall is defining “Header Interfaces”18 that simply mimic the public methods of an implementation. Instead, we should define interfaces based on the role they play for the application.
Repositories: The Memory Keepers
Repositories are a special type of Output Port. While they manage persistence, they belong to the domain’s conceptual world. A modern best practice in this architecture is Interface Slicing. Instead of creating one giant, generic repository that exposes every CRUD operation, we should create fine-grained interfaces for specific needs.
Consider this Sliced Repository example:
// Fine-grained interfaces in the Domain/Application layer
public interface CreateCustomerOutputPort {
void save(final Customer customer);
}
public interface FindCustomersOutputPort {
Optional<Customer> findById(final CustomerId id);
List<Customer> findAll();
}
// Implementation in the Adapter layer
@Repository
@RequiredArgsConstructor
public class JPACustomerRepository implements CreateCustomerOutputPort, FindCustomersOutputPort {
private final SpringCustomerRepository springCustomerRepository; // Internal Spring Data repo
private final CustomerMapper customerMapper;
@Override
public void save(final Customer customer) {
final JpaCustomer jpaCustomer = this.customerMapper.convertToJpaEntity(customer);
this.springCustomerRepository.save(jpaCustomer);
}
// ... implementation of findAll
}
This approach prevents “Leaky Abstractions” where database-specific methods pollute the domain and adheres to the Interface Segregation Principle19.
The Invisible Pillars (Technical Concerns)
Having built the visible structure, we must now attend to the invisible pillars that support our architecture: Data Flow, Control Flow, Error Handling, and Validation.
Data Flow and Mapping Strategies
How does data cross the boundaries of our hexagon? We generally identify three strategies for mapping data between layers:
- Bypass Mapping: The Domain object is used everywhere (Controller -> Service -> Repository). This is simple, but creates tight coupling and leaky abstractions. It is a path of least resistance that often leads to technical debt.
- Bridge Mapping: Adapters have their own models, but map them to the Domain object before passing them to the core. This is better, but the Domain is still exposed to the outer layers.
- Barrier Mapping (Recommended): Full separation. Input adapters map Requests to Commands. Services map Commands to Domain objects. Output ports accept DTOs20 or Persistence Entities mapped from Domain objects. This creates a “firewall” around the core, ensuring true separation of concerns.
The golden rule of Data Types is: Boundary Types (Requests, DTOs) and Adapter Types (JPA Entities) must never leak into the Domain Types.
Control Flow: Orchestrating the Symphony
Control Flow represents the heartbeat of our system. We must distinguish between Coordination (managing interactions within a use case) and Orchestration21 (managing complex workflows across the system).
We can also leverage CQS (Command Query Separation)22. Methods should either change state (Command) or return data (Query), but not both. Furthermore, CQRS (Command Query Responsibility Segregation)23 allows us to split the system into Write models (handled by the Domain) and Read models (Queries potentially bypassing the domain for performance), optimizing each for its specific task.
Error Handling: The Either Revolution
In the Java world, we have an addiction to Exceptions. But Exceptions act like a “GOTO” statement, disrupting control flow and violating the Principle of Least Astonishment24. Why should a “User Not Found” scenario—a perfectly valid business outcome—cause the program execution to jump unexpectedly?
Instead of throwing exceptions for business errors, we should treat errors as values. We use the Either<L, R> monad (often from libraries like Vavr25). The Left side holds the error (Business or Technical), and the Right side holds the success value.
Consider this refactoring from Exceptions to Either26:
Traditional (Bad):
// Opaque signature, relies on hidden exceptions
public List<DossierDTO> fetchPortfolio(String user) {
// ... checks
if (portfolio == null) throw new PortfolioNotFoundException();
return portfolio;
}
Error Values (Good):
// Explicit signature declaring the error possibility
public Either<ApplicationError, List<DossierDTO>> fetchPortfolio(final String user) {
try {
List<DossierDTO> portfolio = externalService.get(user);
if (portfolio == null) {
return Either.left(new PortfolioNotFoundError(user));
}
return Either.right(portfolio);
} catch (RestClientException e) {
return Either.left(new PortfolioServiceNotAvailableError(e));
}
}
This forces the consumer to handle the error explicitly, leading to safer and more predictable code.
Validations: The Always-Valid Domain Model
We must distinguish between Syntactic Validation (checking input formats like email regex) and Semantic Validation (checking business invariants like “email must be unique”).
To enforce the Always-Valid Domain Model27, an object should never exist in an invalid state. The “isValid()” method approach is flawed because it implies the object was created in an invalid state first. Instead, we use Static Factory Methods to validate before instantiation.
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class User {
private final String email;
// Factory method returning a Validation object (Vavr)
public static Validation<List<DomainError>, User> validateThenCreate(final String email) {
return isValidDomainEmail(email)
? Validation.valid(new User(email))
: Validation.invalid(List.of(new DomainError("Invalid email domain")));
}
}
This ensures that if you hold a reference to a User object, it is guaranteed to be valid.
Transactionality: A Framework-Agnostic Output Port
Transactionality is crucial for data integrity, but we should not pollute our domain with framework-specific annotations like Spring’s @Transactional. Instead, we define transactionality as an Output Port using a custom @Transactional annotation (in a Shared Kernel28) and use an Adapter (via Spring AOP) to map it to the actual transaction manager at runtime.
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Transactional {
// Custom properties for rollback rules with Either
Class<?>[] rollbackForWithEither() default {};
}
This keeps our Application Core completely framework-agnostic.
Compliance and Fulfillment
How do we ensure our code actually reflects these principles? We need a strategy for code organization and automated enforcement.
Architectural Cartography: Blueprinting the Code
The structure of your code should “scream” its architecture. We should avoid organizing by “kind” (putting all services in one services package, all controllers in controllers). Instead, we use an Architecturally Expressive strategy that mirrors the hexagonal structure.
A recommended multi-module Maven structure looks like this:
hexagonal-ref-app(Root)shared-kernel: Common DTOs, Errors, Domain Events, Validations.application-core: The Hexagon itself.domain: Entities, Value Objects.input-ports: Use Cases interfaces, Commands/Queries.application: Handlers/Services implementation.output-ports: Interfaces for external needs.
adapters:api-adapter: REST Controllers (Primary Adapter).persistence-adapter: JPA Repositories (Secondary Adapter).
spring-boot-assembly: The execution entry point.
Crucially, we use Java’s package-private visibility to enforce boundaries. Only Ports (Interfaces) and DTOs should be public. The implementations (like our previous example of CreateArticleHandler) should be hidden from the rest of the world, accessible only via dependency injection.
ArchUnit: The Guardian of Integrity
We cannot rely solely on developer discipline. We use ArchUnit29 to automate our architectural rules. It acts as a tireless sentinel.
We can define rules such as “Adapters must not depend on other Adapters” or “Domain must not depend on Frameworks.”
Example Rule (Command Checker):
final ArchRule commandDependencyRule = classes()
.that().resideInAnyPackage("..application.command..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage("java..", "..shared..", "..application.command..");
This ensures Commands remain pure and don’t depend on the Web or Persistence layers. By using FreezingArchRule, we can apply these rules to legacy projects, “freezing” current violations while preventing new ones, allowing for gradual improvement.
Testing Strategy
Testing in a Hexagonal Architecture is simplified by the strict separation of concerns. We employ specific typologies to target each layer effectively.
1. Unit Testing Static Factory Methods
We test the domain logic in complete isolation. Since our objects are “Always Valid,” we must verify that the factories reject invalid input.
@ParameterizedTest
@MethodSource("invalidArguments")
void validateThenCreateShouldReturnError(String email) {
Validation<Error, User> result = User.validateThenCreate(email);
assertThat(result.isInvalid()).isTrue();
}
2. Unit Testing Handlers
Handlers are orchestrators. To test them, we use Mockito30 to mock the Output Ports. We are not testing the database; we are testing that the handler calls the database port correctly.
@ExtendWith(MockitoExtension.class)
class DownloadArticleHandlerTest {
@Mock DownloadArticleOutputPort outputPort;
@InjectMocks DownloadArticleHandler handler;
@Test
void handleShouldReturnContent() {
// given
given(outputPort.download(any(), any())).willReturn(Either.right(content));
// when
var result = handler.handle(query);
// then
assertThat(result.isRight()).isTrue();
verify(outputPort).download(any(), any());
}
}
This ensures the orchestration logic is correct without needing a real database connection.
3. Unit Testing Adapters
Adapters are heterogeneous and require specific strategies.
- REST Controllers: We can test that they correctly map HTTP requests to Commands and DTOs to Responses.
- External APIs: We avoid making real network calls. We can use
MockRestServiceServer(if using Spring’s RestClient) orMockedConstruction(if using generated OpenAPI31 clients) to simulate API behavior.
Testing a Controller with Mockito:
@Test
void profileShouldReturnResponse() {
// given
given(queryBus.query(any())).willReturn(Either.right(userProfile));
// when
ResponseEntity<?> response = controller.profile(auth, request);
// then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
From Blueprint to Reality (Reference Application)
To cement these concepts, let us dissect the “Create Article” flow in our reference application32, a fully-fledged Hexagonal Masterpiece.
1. The Input Port (Use Case)
Located in application-core/input-ports. It extends CommandHandler and uses a self-validating Command. This defines the contract.
@UseCase
public interface CreateArticleUseCase extends CommandHandler<CreateArticleCommand> {}
@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class CreateArticleCommand implements Command {
// fields...
public static Validation<Error, CreateArticleCommand> validateThenCreate(...) { ... }
}
2. The Application Service (Handler)
Located in application-core/application. It orchestrates the flow.
@ApplicationService
@RequiredArgsConstructor
final class CreateArticleHandler implements CreateArticleUseCase {
private final AuthorOutputPort authorOutputPort;
private final ArticleRepository articleRepository;
@Override
@Transactional
public Either<Error, Void> handle(final CreateArticleCommand command) {
return this.authorOutputPort.lookupAuthor(command.authorId())
.flatMap(author -> ArticleMapper.INSTANCE.toArticle(command, author).toEither())
.flatMap(this.articleRepository::save);
}
}
Notice the use of flatMap to chain operations. If any step fails (returns Either.Left), the chain stops, and the error propagates. If it succeeds, it flows to the next step.
3. The Domain Entity
Located in application-core/domain. It uses the Factory Method pattern to ensure validity upon creation.
public class Article {
public static Validation<Error, Article> validateThenCreate(...) {
// validations...
return Validation.valid(new Article(...));
}
}
4. The Output Port
Located in application-core/output-ports.
@OutputPort
public interface AuthorOutputPort {
Either<Error, AuthorDTO> lookupAuthor(final String id);
}
5. The Adapter (External API)
Located in author-external-adapter. It implements the port and handles the gritty details of the REST call.
@Adapter
@RequiredArgsConstructor
final class AuthorExternalAPIAdapter implements AuthorOutputPort {
private final RestClient restClient;
@Override
public Either<Error, AuthorDTO> lookupAuthor(String id) {
// Uses Try to capture exceptions from the REST call and convert them to Error values
// Returns Either.right(dto) or Either.left(error)
}
}
6. The Assembly
Finally, in spring-boot-assembly, we use @ComponentScan with our custom filters to wire everything together. This is where the magic happens, turning our isolated components into a running application.
We use Dependency Injection (DI) to wire our components together. To make our code even more expressive, we define custom annotations that serve as architectural signposts: @Adapter, @ApplicationService, and @DomainService.
@ComponentScan(basePackages = "com.emedina", includeFilters = @ComponentScan.Filter(
type = FilterType.ANNOTATION,
classes = {Adapter.class, ApplicationService.class, DomainService.class}
))
This tells Spring exactly what to look for, turning our DI configuration into a semantic map of our architecture.
Conclusion: Hexagonal Horizons
We have traversed a vast landscape, moving from the messy reality of “Spaghetti Code” and “Lasagna Architecture” to the structured elegance of the Hexagonal Citadel. We have embarked on a quest to fulfill the promises of our Hexagonal Architecture, deliberately steering clear of the siren song of implementation details.
This journey has been more than a technical exercise; it has been a manifesto for clean, maintainable, and expressive code. We have witnessed how a codebase can scream its purpose and semantics. Imagine a bug cropping up in article updates. In a traditional application, this might spark a wild goose chase through sprawling service classes. But in our hexagonal city-state, the path is clear. We would swiftly navigate to the input-ports module, then trace our steps left to the api-adapter and right through application-core to output-ports—no hesitation, no false leads.
We have embraced error values over exceptions, made the implicit explicit, and decomposed concerns into modules that tell a story. Most crucially, we have fortified our domain, that precious core of business logic, against the shifting sands of external frameworks and technologies.
This clarity is about empowering developers. It is about creating systems that are built to evolve. Whether you are refactoring an existing Goliath or crafting a new David, the principles we have explored provide a robust foundation. Our Hexagonal Architecture is not just about drawing boundaries; it is about creating a living, breathing ecosystem where business logic thrives.
So, dear architect, as you stand at the threshold of your next project, armed with the insights from this journey, embrace the hexagonal way. Let your code sing its purpose, let your architecture breathe flexibility, and let your creative spirit soar in this newfound architectural freedom. The hexagonal revolution awaits—are you ready to lead the change?
This article tries to summarize the much more in-depth and comprehensive exploration of the Hexagonal Architecture covered in the book “Decoupling By Design: A Pragmatic Approach to Hexagonal Architecture”.
- Spaghetti Code – https://en.wikipedia.org/wiki/Spaghetti_code ↩︎
- Spaghetti Code with Meatballs – https://medium.com/@fernando.a.cuenca/spaghetti-with-framework-meatballs-4ac6760721b9 ↩︎
- Ravioli Code – https://swizec.com/blog/the-italian-foods-theory-of-bad-software-design/ ↩︎
- Lasagna Code – https://swizec.com/blog/the-italian-foods-theory-of-bad-software-design/ ↩︎
- Hands in the Pants – https://itnext.io/anti-patterns-and-code-smells-46ba1bbdef6d ↩︎
- Anemic Domain Model – https://martinfowler.com/bliki/AnemicDomainModel.html ↩︎
- One Liner – https://mytwoocentsdotcom.wordpress.com/2017/03/25/one-line-methods-can-easily-become-an-antipattern/ ↩︎
- Happy Path – https://en.wikipedia.org/wiki/Happy_path ↩︎
- Dependency Inversion – https://stackify.com/dependency-inversion-principle/ ↩︎
- Clean Architecture – https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html ↩︎
- Command – https://en.wikipedia.org/wiki/Command_pattern ↩︎
- Command Bus – https://ducmanhphan.github.io/2020-12-02-command-bus-pattern/ ↩︎
- Single Responsibility – https://stackify.com/solid-design-principles/ ↩︎
- Open-Closed – https://stackify.com/solid-design-open-closed-principle/ ↩︎
- Value Object – https://martinfowler.com/bliki/ValueObject.html ↩︎
- Domain Driven Design – https://en.wikipedia.org/wiki/Domain-driven_design ↩︎
- Role Interface – https://martinfowler.com/bliki/RoleInterface.html ↩︎
- Header Interface – https://martinfowler.com/bliki/HeaderInterface.html ↩︎
- Interface Segregation – https://stackify.com/interface-segregation-principle/ ↩︎
- Data Transfer Object – https://en.wikipedia.org/wiki/Data_transfer_object ↩︎
- Orchestration vs Coordination – https://www.linkedin.com/pulse/orchestration-vs-coordination-engineering-managers-guide-radu-macovei-gv3jf/ ↩︎
- Command-Query Separation – https://en.wikipedia.org/wiki/Command%E2%80%93query_separation ↩︎
- CQRS – https://martinfowler.com/bliki/CQRS.html ↩︎
- POLA – https://en.wikipedia.org/wiki/Principle_of_least_astonishment ↩︎
- vavr.io – Functional Programming for Java ↩︎
- Either monad – https://docs.vavr.io/#_either ↩︎
- Always-Valid Domain Model – https://enterprisecraftsmanship.com/posts/always-valid-domain-model/ ↩︎
- Shared Kernel – https://github.com/emedina/shared-kernel ↩︎
- ArchUnit – https://www.archunit.org/ ↩︎
- Mockito – https://site.mockito.org/ ↩︎
- OpenAPI Initiative – https://www.openapis.org/ ↩︎
- Hexagonal Ref App – https://github.com/emedina/hexagonal-spring-ref-app ↩︎

This article is part of the JAVAPRO magazine issue:
From Coder To System Designer
Understand what it means to move from coding to designing systems in the age of AI.
Take a closer look at modern Java platforms, architectural thinking, and the responsibilities that come with shaping complex software systems.
Discover the edition →