← Back to Blog
Architecture

Enforcing a hexagonal architecture with code

Konsist, ArchUnit, eslint-plugin-boundaries: make a hexagonal architecture executable by automating the verification of dependency rules between layers.

📅 ✍️ Antoine Coulon
hexagonal-architecturekonsistarchunitfitness-functionskotlin

An architecture only truly exists if it is enforced. You can document a hexagonal architecture in a wiki, draw it on a whiteboard, bring it up in code review: as long as nothing concretely prevents a developer from making the domain depend on an adapter, the boundary is nothing more than a convention. And conventions, as a team grows and pressure mounts, always end up eroding.

The real question, then, is not “how do we describe our architecture?” but “how do we make it executable?”. How do we turn a dependency rule between layers into something a machine can verify, repeatably, on every commit. That is exactly the role of architecture verification tools, and it is the practice we started applying in my team at sunday.

Why automate architecture verification

As teams grow, a gap widens between the architecture as it was intended and the architecture as it is actually implemented. Nobody deliberately introduces a forbidden dependency: it happens because a rule isn’t known, because it’s the easy path in a moment of rush, or simply because the boundary was never written down in any binding way. Once the first violation is introduced, it serves as a precedent, and the erosion accelerates.

Relying on code review alone to catch these drifts is fragile. Review depends on the vigilance of a human who must, on top of everything else, keep the project’s entire set of dependency rules in mind. This is exactly the kind of task humans are bad at and machines are excellent at: mechanically checking, without fatigue and without exception, that a set of constraints is respected.

Hence the value of tooling this verification. Rather than hoping the standards are respected, we make them impossible to break without the pipeline flagging it. The architecture then moves from the status of passive documentation to that of a testable property of the system, what is often called a fitness function: an automated function that measures whether the code respects an expected architectural characteristic.

One tool per ecosystem

The good news is that this idea is widespread enough that a mature tool exists in most ecosystems. The three we looked at closely:

The principle is the same everywhere: you declare the layers (or modules) of the architecture, then express the allowed and forbidden dependency relationships between them. The tool then walks the source code and flags any violation.

The rules of a hexagonal architecture

Let’s quickly recall the layers of a hexagonal architecture (or ports & adapters), since they are the ones we are going to constrain:

In this context, the dependency rules we want to enforce are the following:

  1. The Domain depends neither on the Use Cases, nor on the APIs, nor on the SPIs. It’s the core: it knows nothing of the world around it.
  2. The APIs (Primary Adapters) depend on the domain, but do not depend directly on the SPIs.
  3. The SPIs (Secondary Adapters) depend on the Domain Ports and implement them. They may also depend on the APIs of another module (bounded context).
  4. The Use Cases depend on the domain and the Domain Ports, but do not depend directly on the SPIs.

Expressed in prose, these rules look like good intentions. The goal is to turn them into executed code.

In practice with Kotlin and Konsist

Here is how these four rules translate concretely with Konsist. You declare each layer as a Layer identified by its root package, then express the expected dependsOn / doesNotDependOn relationships. The whole thing is wrapped in a test: it will therefore be run as often as the test suite, both locally and in continuous integration.

class ArchitectureRules {

    @Test
    fun `Architecture dependencies rules`() {
        Konsist.scopeFromProduction()
            .assertArchitecture {
                // Infrastructure, Primary Adapters (API) and Secondary Adapters (SPI)
                val apiLayer = Layer(name = "Infrastructure_API", rootPackage = "com.<package>..infrastructure.api..")
                val spiLayer = Layer(name = "Infrastructure_SPI", rootPackage = "com.<package>..infrastructure.spi..")
                // Domain
                val domainLayer = Layer(name = "Domain", rootPackage = "com.<package>..domain..")
                val domainPortLayer = Layer(name = "Domain_Ports", rootPackage = "com.<package>..domain..ports..")
                // Use Cases
                val useCases = Layer(name = "Use_Cases", rootPackage = "com.<package>..usecases..")

                // API and SPI are the ones depending on domain layer and domain ports
                apiLayer.dependsOn(domainLayer)
                apiLayer.doesNotDependOn(spiLayer)
                spiLayer.doesNotDependOn(apiLayer)
                spiLayer.dependsOn(domainPortLayer, apiLayer)

                // Use cases should depend on domain layer and domain ports, but not on API or SPI
                useCases.dependsOn(domainLayer, domainPortLayer)
                useCases.doesNotDependOn(apiLayer, spiLayer)

                // Domain does not depend on API or SPI
                domainLayer.doesNotDependOn(apiLayer, spiLayer)
                domainPortLayer.doesNotDependOn(apiLayer, spiLayer)
            }
    }
}

What’s interesting here is the direct correspondence between intent and code. The comment “Domain does not depend on API or SPI” reads immediately in the two calls domainLayer.doesNotDependOn(...) and domainPortLayer.doesNotDependOn(...) that follow it. There is no longer any possible gap between the rule as stated and the rule as verified: they are one and the same thing. The day someone introduces an import from the domain to an adapter, this test fails, and the drift is stopped before it even reaches the main branch.

It is this property that gives the approach its value: the architecture becomes a test, that is, an active safety net, and no longer a document consulted with hope.

The feedback loop could go further

Integrating these rules into the test suite is already a considerable step forward: tests are run very frequently, both locally and in the continuous integration pipeline, so a violation is detected quickly. It’s an excellent first step.

Ideally, though, we’d want to shorten the feedback loop even further. Rather than waiting for the tests to run, we’d like these rules to be flagged directly in the IDE, as the code is being written, in the manner of an ESLint. On the Kotlin side, this would go through a linter like Detekt: the architectural error would then appear as an immediate warning, right where the bad dependency is introduced, instead of being discovered after the fact.

This is, by the way, a natural advantage of eslint-plugin-boundaries in the JavaScript / TypeScript ecosystem: being an ESLint plugin, it provides this instant feedback in the editor. The lesson is that the value of an automated architecture rule grows with how quickly it surfaces the information. The earlier the signal arrives, the cheaper the fix.

Conclusion

Enforcing an architecture with code means refusing to let the most important boundaries of a system rest on collective discipline alone. Konsist, ArchUnit and eslint-plugin-boundaries share the same promise: turning dependency rules stated in prose into constraints verified by the machine, repeatably and without exception.

The benefit isn’t measured only in bugs avoided, but in clarity preserved over time. A hexagonal architecture whose layers are checked automatically stays faithful to its original intent even after dozens of contributors and hundreds of commits. Starting with architecture tests is simple and accessible; aiming next for feedback in the IDE is the next step up. In both cases, the principle remains the same: an architecture you make executable is an architecture that lasts.