Skip to content
Riadh Mnasri
← Back to blog
3 min read

Hexagonal Boundaries in Kotlin: Gradle Modules, Not Folders

The article on hexagonal architecture in practice already made the point: the question that matters isn't "is this in the right folder" but "does the domain still compile on its own if I delete the adapters." A domain/ folder never really answers that question, it only suggests an intent. The only way to turn that intent into a guarantee is to have the compiler check it, not a code review.

What a folder can't prevent#

Nothing, in a single-module Gradle project, stops a file in the domain/ folder from importing org.springframework.stereotype.Component or jakarta.persistence.Entity. The code compiles, the tests pass, and the hexagonal boundary exists only in the head of whoever wrote the code and whoever reviewed it. It's a discipline, not a constraint: it holds only as long as nobody forgets it under deadline pressure.

The boundary as a Gradle dependency, not a folder convention#

A dedicated domain module, with no dependency on Spring, Jakarta, or a database driver, turns that discipline into a compilation constraint. If a file in the domain module imports a Spring class, the build fails with a resolution error, not a comment in code review.

kotlin
// settings.gradle.kts
include("domain", "application", "infrastructure", "presentation")
kotlin
// domain/build.gradle.kts
dependencies {
    // nothing but the language itself
    implementation(kotlin("stdlib"))
}
kotlin
// application/build.gradle.kts
dependencies {
    implementation(project(":domain"))
    // still no framework dependency here
}
kotlin
// infrastructure/build.gradle.kts
dependencies {
    implementation(project(":domain"))
    implementation(project(":application"))
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
}

The dependency graph Gradle refuses to let you invert#

infrastructure -> application -> domain
presentation   -> application

domain never depends on a module upstream of it: Gradle
literally offers no way to declare the opposite without
creating a cycle, which the build rejects on its own.

An import going from domain back up to infrastructure doesn't produce a misplaced class, it produces a module cycle that Gradle refuses to resolve. The violation becomes a build error, earlier and more systematically than a code review comment that depends on the reviewer's vigilance.

What it costs#

This rigor has a price: more build.gradle.kts files to maintain, a slightly longer Gradle configuration time, and extra gymnastics every time a type has to cross a module boundary (more mapping DTOs, where a single module sometimes let a domain entity slip straight through to the controller). On a project spanning a few weeks, that cost far outweighs the benefit. On a project built to last years, with several contributors who won't all have read the same architecture documentation, having the compiler check the boundary instead of the team's collective memory pays for itself the first time it prevents a silent regression.

Where to apply this#

A project like MissionMatch, where the hexagonal boundary today is a matter of folders and team discipline, is a natural candidate for this kind of reinforcement once the number of contributors grows. As long as the project is driven by one person who knows every boundary by heart, the folder is enough. Once that stops being true, having the compiler check the boundary becomes more reliable than counting on the team's memory.