← Back to the section

Hexagonal Architecture is not just an agreement to name folders core/ and adapter/. It is about compile-time boundaries: the compiler must physically forbid the wrong dependencies. Otherwise the architecture exists only in slide decks, and a year later nothing of it remains in the code.

In Java this is achieved with a multi-module Gradle project. Each module is a separate compile zone. A class in module A cannot see a class in module B unless module A's build.gradle declares an explicit dependency on B. There is no way to sneak around it — you get a compilation error.

Why multiple modules instead of packages

Imagine the entire service as a single module with core/ and adapter/ packages:

<service>/
└── src/main/java/
    ├── core/...
    └── adapter/...

Formally it looks like Hexagonal. But it is one compile zone. A developer adds import org.springframework.* to a file in the core/ package — and the IDE does not object, the test passes, and it can slip through review unnoticed. Six months later core/ is no longer clean: it has Spring, it has JPA annotations, it has anything and everything.

Multi-module Gradle is the only way to make the boundary real:

<service>/
├── core/                      # pure Java, no Spring
├── persistence/               # database access
├── user-api-in-adapter/       # REST for users
├── admin-api-in-adapter/      # REST for administrators
├── kafka-in-adapter/          # Kafka consumers
├── kafka-out-adapter/         # Kafka producers
├── sber-out-adapter/          # Sber integration
├── sms-out-adapter/           # SMS provider
├── scheduler-out-adapter/     # task scheduler
└── bootstrap/                 # application assembly point

All modules are listed in settings.gradle.kts:

include(
    ":core",
    ":persistence",
    ":user-api-in-adapter",
    ":admin-api-in-adapter",
    ":kafka-in-adapter",
    ":kafka-out-adapter",
    ":sber-out-adapter",
    ":sms-out-adapter",
    ":scheduler-out-adapter",
    ":bootstrap"
)

The minimal starting set: core/, persistence/, one *-in-adapter, bootstrap/. The rest are added as the service grows.

core/ — the module without Spring

core/ is the heart of the application. This is where business rules, aggregates, use cases, and port interfaces live. And this is exactly where Spring is not needed.

// core/build.gradle.kts
dependencies {
    implementation("org.projectlombok:lombok")
    annotationProcessor("org.projectlombok:lombok")
    implementation("ru.vikulinva:ddd-building-blocks:1.x")
    implementation("ru.vikulinva:usecase-pattern:1.x")
    implementation("ru.vikulinva:hexagonal-architecture:1.x")
    implementation("jakarta.validation:jakarta.validation-api")
    // Spring, jOOQ, Jackson, OkHttp, Kafka — none of this is here
}

What this buys you in practice:

  • Trying to write import org.springframework.* in core/ is a compilation error. The boundary is not "by agreement", it is physical.
  • Tests on aggregates and use cases run in milliseconds: no @SpringBootTest, no Testcontainers, no warm-up needed. You can run them by the thousands.
  • That same core/ can be plugged into a Lambda, a CLI tool, or a batch job — without rewriting anything.

For more on what exactly lives inside core/, see the article Core layer.

Out-adapters: one module per system

Each external system is a separate Gradle module:

persistence/               # PostgreSQL via jOOQ
sber-out-adapter/          # Sber API via RestClient
sms-out-adapter/           # SMS provider
kafka-out-adapter/         # sending to Kafka
s3-out-adapter/            # object storage
scheduler-out-adapter/     # @Scheduled jobs

Why this granularity:

Dependency isolation. core/ knows nothing about the Sber SDK. persistence/ knows nothing about Kafka. When the SMS provider changes, the fix is in a single sms-out-adapter/; the other modules are not rebuilt.

Independent upgrades. The Sber SDK is updated to a new version — you update one module, test it, ship it. Not "we bumped a library across the whole service and now everything needs re-checking".

Per-system tuning. HTTP client, timeouts, retries, circuit breakers — each out-adapter tunes them for its own system. A single shared HTTP client for all outbound traffic is a common mistake.

Each out-adapter depends only on core/, which holds the port interfaces it implements:

// persistence/build.gradle.kts
dependencies {
    implementation(project(":core"))   // core only
    implementation("org.jooq:jooq")
    // NO dependencies on other adapters
}

In-adapters: one module per entry type

Entry points into the application are split as well:

user-api-in-adapter/    # REST for end users
admin-api-in-adapter/   # REST for administrators
kafka-in-adapter/       # Kafka consumers as an entry point
cli-in-adapter/         # command line or batch (if any)

The main reason is a different security model. user-api-in-adapter accepts a JWT from Keycloak with one set of roles. admin-api-in-adapter accepts a JWT with a different audience and possibly mTLS. Two separate modules mean two separate SecurityFilterChains that physically cannot get mixed up.

An additional benefit: each in-adapter has its own OpenAPI file. The user API is published for client teams, the admin API is internal. Not one big specification lumping everything together.

If user and admin endpoints live in the same *-in-adapter, one SecurityFilterChain serves both contracts. Any mistake in an access annotation affects both at once. Mistakes of this kind usually are not discovered right away.

bootstrap/ — the assembly point

bootstrap/ is the only place that knows about all the other modules. It depends on all of them, and nobody depends on it.

// bootstrap/build.gradle.kts
dependencies {
    implementation(project(":core"))
    implementation(project(":persistence"))
    implementation(project(":user-api-in-adapter"))
    implementation(project(":admin-api-in-adapter"))
    implementation(project(":kafka-in-adapter"))
    implementation(project(":sber-out-adapter"))
    implementation(project(":sms-out-adapter"))
    implementation(project(":scheduler-out-adapter"))
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
}

What lives here:

  • <App>Application.java with @SpringBootApplication.
  • application.yml and profile configs (application-local.yml, application-prod.yml).
  • @Configuration classes for wiring beans that are not picked up by component scan.
  • Dockerfile, Helm chart.

What does not live here: business logic, controllers, repositories. bootstrap/ is glue, not functionality.

More in the article Bootstrap / composition root.

Dependency direction

The main rule from which everything else follows:

bootstrap → core ← adapters
  • bootstrap depends on all modules.
  • Each adapter depends only on core — through port interfaces.
  • core depends on no adapter.
  • Adapters do not depend on each other. Coordination between them is a use case in core/.

If core/ turns out to need a dependency on persistence/, that is a signal something extraneous is sitting in core/ (for example, generated jOOQ classes that have no place there).

Gradle checks this at build time. Violating the dependency direction gets you a compilation error. That is the compile-time guarantee no style guide can give you.

In short

  • Multi-module Gradle is not extra complexity but the mechanism that makes architectural boundaries physical.
  • core/ without Spring: any attempt to add import org.springframework.* is a compilation error. Tests are fast, the domain is clean.
  • Out-adapters are split by external system: one module — one system, independent dependencies and configuration.
  • In-adapters are split by entry type: different security models, different OpenAPI specifications, compile-time isolation.
  • bootstrap/ depends on all, nobody depends on it. Configuration only, no logic.
  • The dependency arrow is always: bootstrap → core ← adapters.