JavaClean Architecture
June 6, 2026

Why Hexagonal Architecture Helps Java Applications Survive Change

Most Java applications do not become painful because the first version was badly written. They become painful because every new feature, database change, API integration, or framework decision adds pressure to a structure that was never designed to absorb change. Hexagonal architecture gives developers a practical way to keep business logic protected while allowing the outer technology choices to evolve.

This article focuses on the core lesson from the first chapter: keep the code that represents the business problem separate from the code that talks to databases, user interfaces, message brokers, files, and external systems.

The core problem: business code mixed with technology code

A common source of technical debt is not a single bad class or a single ugly method. It is the absence of a clear boundary. When business rules are mixed with persistence, HTTP, framework annotations, and infrastructure decisions, every change becomes risky.

Imagine a service where the rule for activating an account is directly tied to database access and HTTP response formatting:

public class AccountController {
    private final AccountRepository repository;

    public AccountController(AccountRepository repository) {
        this.repository = repository;
    }

    public HttpResponse activate(String accountId) {
        AccountEntity entity = repository.findById(accountId);

        if (entity == null || entity.isBlocked()) {
            return HttpResponse.badRequest("Account cannot be activated");
        }

        entity.setActive(true);
        repository.save(entity);

        return HttpResponse.ok("Account activated");
    }
}

This works, but the business rule is now surrounded by web and database concerns. Testing the rule requires controller setup and repository behavior. Changing the persistence mechanism may affect the same area where the business rule lives. Adding another way to activate accounts, such as a CLI command or message consumer, can lead to duplicate logic.

The goal of hexagonal architecture is not to make the code look clever. The goal is to make change less dangerous.

The hexagonal idea in one picture

The chapter explains the architecture around three main areas: Domain, Application, and Framework. The important direction is from the outside toward the inside. Technology code should depend on business code, not the other way around.

Outside world
  |
  |  REST, CLI, UI, tests, other systems
  v
+---------------------------+
| Framework hexagon         |
| input adapters            |
| output adapters           |
+-------------+-------------+
              |
              v
+---------------------------+
| Application hexagon       |
| use cases                 |
| input ports               |
| output ports              |
+-------------+-------------+
              |
              v
+---------------------------+
| Domain hexagon            |
| entities                  |
| value objects             |
| business rules            |
+---------------------------+

The Domain hexagon contains the concepts that model the problem. The Application hexagon coordinates use cases. The Framework hexagon contains the adapters that connect the application to the outside world.

Domain hexagon: protect the business model

The Domain hexagon is where the business rules live. In a Java application, this usually means plain classes, records, enums, and methods that express real concepts from the problem domain. The domain should not need to know whether data comes from MySQL, PostgreSQL, a file, a REST API, or a mock used in a test.

A simple domain object should express behavior in domain terms:

public final class Account {
    private final AccountId id;
    private boolean active;
    private boolean blocked;

    public Account(AccountId id, boolean active, boolean blocked) {
        this.id = id;
        this.active = active;
        this.blocked = blocked;
    }

    public void activate() {
        if (blocked) {
            throw new IllegalStateException("Blocked account cannot be activated");
        }
        this.active = true;
    }

    public AccountId id() {
        return id;
    }

    public boolean active() {
        return active;
    }
}

There is no controller, SQL query, ORM annotation, HTTP status code, or framework dependency here. The object represents the rule. That makes the rule easier to read and easier to test.

Application hexagon: express what the software does

The Application hexagon contains use cases and ports. A use case describes a behavior the software supports. An input port implements that use case. An output port describes what the application needs from outside, without deciding how that external interaction is implemented.

For example, activating an account can be expressed as a use case:

public interface ActivateAccountUseCase {
    Account activate(AccountId accountId);
}

public interface AccountOutputPort {
    Account findById(AccountId accountId);

    void save(Account account);
}

public final class ActivateAccountInputPort implements ActivateAccountUseCase {
    private final AccountOutputPort accountOutputPort;

    public ActivateAccountInputPort(AccountOutputPort accountOutputPort) {
        this.accountOutputPort = accountOutputPort;
    }

    @Override
    public Account activate(AccountId accountId) {
        Account account = accountOutputPort.findById(accountId);
        account.activate();
        accountOutputPort.save(account);
        return account;
    }
}

The input port defines the flow. It fetches the account, asks the domain object to apply the business rule, then persists the result through an output port. It does not know whether the output port is backed by a database, a file, or a fake implementation used in a test.

Framework hexagon: adapt technologies to the application

The Framework hexagon is where technical details belong. If the application exposes a REST API, the REST controller is an input adapter. If it reads from a database, the database implementation is an output adapter. These adapters translate between technology-specific models and the domain/application model.

A REST adapter can call the use case without owning the business rule:

public final class AccountRestAdapter {
    private final ActivateAccountUseCase activateAccountUseCase;

    public AccountRestAdapter(ActivateAccountUseCase activateAccountUseCase) {
        this.activateAccountUseCase = activateAccountUseCase;
    }

    public AccountResponse activate(String accountId) {
        Account account = activateAccountUseCase.activate(AccountId.from(accountId));
        return AccountResponse.from(account);
    }
}

A database adapter can implement the output port:

public final class AccountDatabaseAdapter implements AccountOutputPort {
    private final AccountJpaRepository repository;

    public AccountDatabaseAdapter(AccountJpaRepository repository) {
        this.repository = repository;
    }

    @Override
    public Account findById(AccountId accountId) {
        AccountEntity entity = repository.findRequired(accountId.value());
        return AccountMapper.toDomain(entity);
    }

    @Override
    public void save(Account account) {
        repository.save(AccountMapper.toEntity(account));
    }
}

The important part is the dependency direction. The database adapter depends on the output port and domain model. The domain model does not depend on the database adapter.

Driving and driven operations

The chapter separates outside interactions into two useful categories: driving operations and driven operations.

  • Driving operations: actions that start behavior in the application. Examples include a REST request, a CLI command, a UI action, or a test agent.
  • Driven operations: actions started by the application to reach external resources. Examples include reading from a database, writing to a queue, calling another system, or loading data from a file.
Driving side                              Driven side

User / UI / REST / CLI                    Database / File / Queue / API
        |                                           ^
        v                                           |
  Input adapter                              Output adapter
        |                                           ^
        v                                           |
  Input port  ---->  Domain rules  ---->  Output port

This distinction helps when deciding where a class belongs. If it receives a request and starts a use case, it is probably an input adapter. If it talks to an external resource on behalf of a use case, it is probably an output adapter.

Why this matters in real projects

Hexagonal architecture is useful when software is expected to live for a long time and change repeatedly. It gives the team a shared structure for adding features without guessing where code should go.

The main benefits are practical:

  • Change tolerance: business rules can survive changes in databases, APIs, messaging tools, or user interfaces.
  • Maintainability: developers know where to look when a business rule, use case, or adapter needs to change.
  • Testability: domain and application behavior can be tested without starting the full infrastructure.
  • Technology flexibility: the same use case can be exposed through REST today and CLI, messaging, or another protocol later.

A small testing advantage

Because the use case depends on an output port interface, a test can provide a fake implementation. No database is required to test the application flow.

public final class InMemoryAccountOutputPort implements AccountOutputPort {
    private final Map<AccountId, Account> accounts = new HashMap<>();

    @Override
    public Account findById(AccountId accountId) {
        return accounts.get(accountId);
    }

    @Override
    public void save(Account account) {
        accounts.put(account.id(), account);
    }

    public void add(Account account) {
        accounts.put(account.id(), account);
    }
}

This is one of the strongest reasons to care about boundaries. If the business and application code can be tested without the UI and database, feedback becomes faster, and design problems become easier to see.

Common mistakes to watch for

  • Treating hexagonal architecture as folders only: package names do not create architecture. Dependency direction does.
  • Putting business rules in adapters: controllers, repositories, and messaging consumers should translate and delegate. They should not own core rules.
  • Letting framework annotations leak inward: the deeper the code is, the less it should know about frameworks.
  • Creating ports for everything: not every class needs an interface. Use ports where the application crosses a boundary.
  • Overengineering small throwaway projects: The chapter is clear that architecture has a cost. Use this approach when maintainability and change tolerance matter.

Checklist for applying hexagonal architecture

  • Can the domain model compile without web, database, or messaging dependencies?
  • Are business rules expressed in domain objects or domain services?
  • Are use cases named around what the software does?
  • Do input ports implement use cases instead of exposing framework-specific details?
  • Do output ports describe what data the application needs, not how it is fetched?
  • Are REST controllers, CLI handlers, database classes, and file readers treated as adapters?
  • Can important behavior be tested without starting the real database or UI?
  • Is the dependency direction pointing inward toward the domain?

Conclusion

Hexagonal architecture is a practical answer to a common Java problem: business rules becoming trapped inside technology decisions. By separating Domain, Application, and Framework concerns, the system becomes easier to change without rewriting the application's core.

The main habit is simple: keep the domain clean, express behavior through use cases and ports, and let adapters handle the outside world. That discipline pays off when requirements change, integrations evolve, and the codebase needs to remain understandable after the first release.

Share:

Comments0

Home Profile Menu Sidebar
Top