← Back to the section

Hexagonal Architecture is a powerful approach, but it comes at a cost. In this article we'll figure out what it gives you, when that cost is justified, and when you're better off with something simpler.

What Hexagonal Architecture is and why it's not for everyone

Picture an ordinary service: a controller accepts a request, calls a service, which goes to the database. Simple and fast. But as the project grows — new integrations appear, business rules get more complex, the team expands — this approach starts to crack. The base layer gets overgrown with Spring dependencies, business logic becomes intertwined with database and HTTP code, and tests require spinning up the full Spring context.

Hexagonal Architecture (also known as ports and adapters) solves this problem through strict separation: business logic lives in a clean core/ module without any framework dependencies. Everything tied to the outside world (database, HTTP, Kafka, SMS) is expressed as an adapter, plugged in through an interface port.

The cost of this solution: several gradle modules, extra mapper classes between layers, ArchUnit tests to enforce the boundaries. On a simple service this cost doesn't pay off. On a complex one, it pays off many times over.

Three levels of service maturity

Before talking about the "time"/"too early" signs, it helps to understand the overall scale. Services vary in complexity, and you pick your architectural approach to match the level:

  • Level 1 — UseCase + Handler + Controller in a single module. No split between domain and infrastructure. Suitable for simple CRUD services and new projects.
  • Level 2 — aggregates with business logic appear, but still in one or two modules. The domain is carved out, but there's no physical core/adapter separation.
  • Level 3 — DDD and Hexagonal together. Several gradle modules, port interfaces in core/, adapters implementing them, ArchUnit tests holding the boundaries. This is what this article is about.

Jumping to Level 3 from Level 1 is like moving into a new office with three meeting rooms when the team is two people.

Signs it's "time"

Here are the signals under which Hexagonal starts delivering a measurable payoff. If at least three of the five match, it's time to migrate.

The service integrates with several external systems. A typical setup — PostgreSQL, a payment provider, Kafka, an SMS gateway. Each system has its own data format, its own retry and timeout settings, its own failure model. Without explicit adapters, all of this gets mixed into a single service class and grows unmanageably.

The domain logic becomes complex. Aggregates with invariants appear — for example, Order.confirm() checks five conditions and emits three events. Business rules flow between scenarios. You want to isolate the domain from infrastructure: change business rules without touching Spring configs.

Three or more entry points. REST for the user, REST for the administrator, scheduled tasks, Kafka consumers — each with its own security model. Without dedicated *-in-adapter/ modules, they start overlapping in configuration and SecurityFilterChain.

Tests require spinning up the whole Spring context. If verifying business logic needs @SpringBootTest — because the domain service depends on Spring-annotated classes — then a clean core/ in Hexagonal solves it. Unit tests on aggregates without Spring run in milliseconds.

A team of three or more developers. When several people edit the same service, architectural boundaries become a social need. An ArchUnit test will catch the "a new developer put a Spring import into core" situation that review would miss. On a team of one or two, verbal agreements work without extra tests.

Signs it's "too early"

And conversely — when the migration will cause more pain than benefit.

A single service with a single database. If the outside world is only PostgreSQL, you don't need Hexagonal. The repository pattern in one module handles the split between domain and storage.

A small team, a small service. One or two developers, up to ten thousand lines of code. Conventions hold verbally. Architectural tests for "what if someone puts something in the wrong place" are redundant work when core is three files.

The business logic is still shifting and searching for its shape. At the start of a project the business model jumps around. Hexagonal, with its mapper classes and ports, slows iterations down: every "let's try it differently" means rewriting several mappers and ports. First you need to find a stable shape, then apply Hexagonal.

No aggregates with invariants. If the domain is just a table with basic CRUD methods, the Hexagonal separation protects nothing. An anemic domain in a Hexagonal wrapper is the most expensive form of an anemic domain.

The trap: cargo-cult and partial adoption

Two common mistakes when adopting Hexagonal.

Cargo-cult — every service on the team is groomed to a single template regardless of its complexity. A three-endpoint service in a Hexagonal layout yields five gradle modules, eight mapper classes, and ArchUnit tests — for what? This is "architecture for architecture's sake." The decision to adopt is made per service, not per team. The same department can have a simple Level 1 service sitting next to a Level 3 billing service.

Partial Hexagonal — there's a core/, but *-in-adapter/ is mixed with REST controllers and business logic. Or there's a persistence/, but calls to the payment provider sit right inside the command handler.

Why this is bad:

  • Compile-time guarantees don't work. If even one module mixes layers, ArchUnit catches some violations but not others. The team loses confidence in the cleanliness of the code.
  • Readability is worse than a monolith. Every time, a developer wonders: "is this part Hexagonal already, or not yet?"
  • Unfinished refactoring piles up. "Finish it someday" usually doesn't get done — the refactoring takes two to four weeks and fits poorly into the normal development cycle.

The rule: either full Hexagonal (all modules, ArchUnit tests in CI, mapper classes) or none. Intermediate states are acceptable only as a short transitional period with an explicit completion deadline.

In short

  • Hexagonal Architecture isolates business logic in a clean core/ without framework dependencies; the cost is extra modules, mapper classes, ArchUnit tests.
  • It's worth migrating if at least three of five signs match: several external integrations, complex domain logic, three or more entry points, tests requiring the Spring context, a team of three or more.
  • Don't migrate with a single database, a small team, unstable business logic, or no aggregates with invariants.
  • Cargo-cult (Hexagonal for all services) is an antipattern. The decision is made per service.
  • Partial Hexagonal is worse than a monolith: you lose the guarantees but get all the complexity.
  • Module structure — what exactly we build once we've decided to migrate.
  • The core layer — what goes into core/ and why.