Kotlin Architecture Tests: Why Konture Exists
Kotlin architecture rules often live in two worlds at once: the Gradle module graph and the Kotlin source tree. Konture was built to test both.
Consider this rule:
The domain layer must not depend on the data layer.
In a Kotlin project, that rule can be broken in at least two different ways.
First, at the build level:
// domain/build.gradle.kts
dependencies {
implementation(project(":data"))
}
Second, at the source level:
package com.acme.domain
import com.acme.data.UserRepositoryImpl
Those are related problems, but they are not the same problem.
One is about the real Gradle module graph. The other is about Kotlin files, packages, imports, declarations, and references.
This is the main reason Konture exists: Kotlin architecture is not only bytecode, not only source files, and not only Gradle modules. It is the relationship between all of them.
The Gap in Existing Solutions
Existing tools are useful. Konture is not trying to erase them.
But Kotlin teams often hit gaps when they need architecture tests that understand both the build structure and the source code.
ArchUnit Is Strong for JVM Bytecode
ArchUnit is a mature and proven tool, especially in Java and JVM backend systems.
It works well when your architecture question can be answered from compiled classes.
That is also its limitation for Kotlin projects. Kotlin developers write source-level constructs that do not always map cleanly to the shape of JVM bytecode. A rule may be easier to express against the Kotlin declaration the developer wrote, rather than the compiled form produced later.
ArchUnit also does not naturally start from Gradle’s project graph. It can reason about class dependencies, but :domain, :data, :feature:checkout:api, and :feature:checkout:impl are build concepts. In a large Gradle build, those concepts matter.
Source-Scanning Tools Are Good at Kotlin Declarations
Source-oriented tools are valuable for package conventions, names, modifiers, annotations, constructor shape, and other Kotlin-level rules.
That solves a different part of the problem.
A source scan can find files. It can inspect packages. It can inspect classes. But a folder scan is not the same as asking Gradle which modules exist, which source sets are production source sets, and which project dependencies are declared.
That distinction becomes important in Android and Kotlin Multiplatform projects.
A Kotlin project may contain:
:app
:core:domain
:core:data
:feature:checkout:api
:feature:checkout:impl
:shared
:androidApp
:iosApp
It may also contain source sets such as:
commonMain
androidMain
iosMain
desktopMain
Folder names alone are not enough. The build already knows what those things mean. Architecture tests should be able to use that knowledge.
Linters Are Not Architecture Testers
detekt and ktlint are excellent at what they are designed to do.
But most architecture rules require whole-project context.
A linter can inspect a file. It usually cannot answer questions like:
- does this Gradle module depend on a forbidden sibling module?
- did a feature implementation module become visible to another feature implementation?
- does the project dependency graph contain a cycle?
- does this public API expose a type owned by another architectural layer?
Those are architectural questions, not formatting questions.
Why Konture Takes a Two-View Approach
Konture combines two views of a Kotlin project.
The first view is the build graph:
- Gradle modules;
- source sets;
- production Kotlin source directories;
- declared project dependencies;
- applied plugin context.
The second view is the Kotlin source model:
- files;
- packages;
- classes and interfaces;
- imports;
- annotations;
- visibility;
- functions and properties;
- references between project classes.
The Gradle plugin captures the build view. The assertion library uses that captured layout and parses Kotlin source with a PSI-based static analysis model.
At a high level, the flow is:
flowchart LR
subgraph GradleView ["1. Build Graph View"]
GB["Gradle Build"] -->|plugin execution| KP["Konture Gradle Plugin"]
KP -->|extracts| LM["Layout Metadata<br/>(modules, source sets, dependencies)"]
end
subgraph SourceView ["2. Source Code View"]
KF["Kotlin Source Files"] -->|static analysis| PA["PSI-based Parser"]
PA -->|extracts| SM["Kotlin Source Model<br/>(packages, imports, declarations)"]
end
LM -->|consumes| AT["Architecture Tests<br/>(assertions)"]
SM -->|consumes| AT
AT --> TR["Test Results<br/>(success / failure)"]
style GradleView fill:#faf5ff,stroke:#c084fc,stroke-width:1px
style SourceView fill:#f0fdf4,stroke:#4ade80,stroke-width:1px
style AT fill:#e0f2fe,stroke:#0284c7,stroke-width:2px,rx:10px
style TR fill:#ecfdf5,stroke:#10b981,stroke-width:2px
That means you can write module rules and source rules in the same test suite.
Konture.architecture {
modules {
that().haveNamePath(":domain")
should().notDependOnModule(":data")
}
classes {
that().resideInAPackage("..domain..")
should().onlyDependOnClassesInAnyPackage(
"..domain..",
"kotlin..",
"java..",
)
}
}
The first rule checks the physical dependency declared in the build.
The second rule checks source-level references inside Kotlin code.
Together, they protect the boundary more completely than either view alone.
What Konture Is
Konture is a standalone Kotlin and Gradle architecture testing library.
It has two main parts:
- a Gradle plugin that extracts the project layout and module dependency graph;
- a Kotlin assertion library that lets you write architecture rules as ordinary tests.
Those tests can run in the test framework you already use, because Konture is not tied to a custom runner.
You can use it with JUnit, Kotest, TestBalloon, or another Kotlin/JVM test runner. The architecture rules are just test code.
What Konture Is Not
Konture is not a style guide.
It does not force Clean Architecture, MVVM, hexagonal architecture, DDD, feature-sliced architecture, or any other pattern.
Konture is architecture-agnostic. It gives you a way to encode the architecture your team already chose.
That distinction matters.
An Android team might protect feature API and implementation modules.
A backend team might protect ports and adapters.
A KMP team might protect shared code from platform app dependencies.
A library team might protect public API packages from leaking implementation classes.
Konture does not decide those policies. It makes them executable.
What Konture Addresses
Konture is aimed at the architecture problems that ordinary compilation and linting do not catch well.
Module Boundaries
Konture.modules {
that().haveNamePath(":core:domain")
should().notDependOnModule(":core:data")
should().notDependOnModule(":app")
}
This checks the real project dependency graph.
Cycles
Konture.assertNoCycles()
This protects the build graph from circular dependencies.
Layer Isolation
Konture.layered {
val presentation = layer("presentation") definedBy "..presentation.."
val domain = layer("domain") definedBy "..domain.."
val data = layer("data") definedBy "..data.."
where(presentation) {
mayOnlyAccessLayers(domain)
}
where(data) {
mayOnlyAccessLayers(domain)
}
where(domain) {
mayOnlyAccessLayers()
}
}
This models a layered dependency direction directly in code.
Source-Level Conventions
Konture.classes {
that().resideInAPackage("..domain..")
that().haveNameEndingWith("Repository")
should().beInterfaces()
}
This catches a common domain convention: repositories are contracts, not concrete data implementations.
Type Leakage
Konture.classes {
that().resideInAPackage("..domain..")
should().notHaveSignaturesWithTypesAnnotatedWith("jakarta.persistence.Entity")
}
This keeps persistence types from leaking into domain APIs.
Visibility Boundaries
Konture.classes {
that().resideInAPackage("..impl..")
should().beInternal()
}
This prevents implementation packages from becoming accidental public API.
File Hygiene
Konture.files {
should().notHaveWildcardImports()
should().haveOnlyOneClassPerFile()
should().haveNameMatchingClassName()
}
This lets teams encode conventions that reduce review noise and keep source structure predictable.
Why Gradle Awareness Matters
Kotlin teams often design systems around modules.
For example:
flowchart TD
App[":app"]
subgraph CheckoutFeature ["Checkout Feature Module"]
CheckoutImpl[":feature:checkout:impl"]
CheckoutApi[":feature:checkout:api"]
end
subgraph ProfileFeature ["Profile Feature Module"]
ProfileImpl[":feature:profile:impl"]
ProfileApi[":feature:profile:api"]
end
subgraph Core ["Core Modules"]
Domain[":core:domain"]
Network[":core:network"]
end
App --> CheckoutImpl
App --> ProfileImpl
CheckoutImpl --> CheckoutApi
CheckoutImpl --> Domain
CheckoutImpl --> Network
ProfileImpl --> ProfileApi
ProfileImpl --> Domain
CheckoutImpl -.->|FORBIDDEN Sibling Dependency| ProfileImpl
style App fill:#f8fafc,stroke:#64748b,stroke-width:1px
style CheckoutImpl fill:#f0f9ff,stroke:#0284c7,stroke-width:1px
style CheckoutApi fill:#e0f2fe,stroke:#0ea5e9,stroke-width:1px
style ProfileImpl fill:#f0f9ff,stroke:#0284c7,stroke-width:1px
style ProfileApi fill:#e0f2fe,stroke:#0ea5e9,stroke-width:1px
style Domain fill:#f0fdf4,stroke:#16a34a,stroke-width:1px
style Network fill:#f0fdf4,stroke:#16a34a,stroke-width:1px
linkStyle 7 stroke:#ef4444,stroke-width:2px,stroke-dasharray: 5 5;
The architecture policy might be:
Feature implementation modules must not depend on other feature implementation modules.
That policy is not only about packages. It is about Gradle project dependencies.
If a developer adds:
implementation(project(":feature:profile:impl"))
to :feature:checkout:impl, the compiler will accept it. Gradle will build it. The shortcut may even solve the immediate feature request.
But the architecture has drifted.
A Gradle-aware architecture test can fail immediately.
Konture.modules {
that().haveNameMatching(":feature:**:impl")
should().onlyDependOnModules(
":feature:**:api",
":core:**",
":shared",
)
}
The important part is that the test is checking modules as modules, not pretending they are just directories.
Why Kotlin Source Awareness Matters
Module boundaries are not enough.
You can have a valid module graph and still leak the wrong concepts inside source code.
For example, :data may correctly depend on :domain, but a domain package might still reference an implementation detail because it is available somewhere in the test or application classpath.
Source rules catch those issues:
Konture.classes {
that().resideInAPackage("..domain..")
should().onlyDependOnClassesInAnyPackage(
"..domain..",
"kotlin..",
"java..",
)
}
For external framework imports, a team can write a custom predicate:
Konture.scopeFromPackage("com.acme.domain")
.assertTrue("Domain must not import framework or persistence APIs") { cls ->
cls.imports.none { fqName ->
fqName.startsWith("org.springframework.") ||
fqName.startsWith("android.") ||
fqName.startsWith("androidx.compose.") ||
fqName.startsWith("jakarta.persistence.")
}
}
That gives the team a concrete rule: domain code can use domain types and standard Kotlin or Java types, but not framework-specific APIs.
Why It Works for AI-Assisted Development
AI-generated code often fails in predictable architectural ways.
It imports the concrete thing because the concrete thing is visible.
It adds a Gradle dependency because that makes the reference compile.
It uses a DTO as a shortcut instead of mapping to a domain model.
It places a new class near a similar class without understanding the package boundary.
Konture gives the repository an executable response.
Instead of relying only on a prompt like:
Follow the project architecture.
the project can run:
./gradlew :konture-test:test
and tell the agent exactly which rule broke.
That is the difference between guidance and enforcement.
Konture Features at a Glance
Konture focuses on practical architecture checks for Kotlin projects:
- Gradle-aware module dependency rules;
- source-level Kotlin PSI analysis;
- architecture-agnostic rule authoring;
- support for JVM, Android, and Kotlin Multiplatform layouts;
- ordinary test execution with common Kotlin test frameworks;
- module, class, file, function, and property assertions;
- layered architecture DSL;
- custom predicates for project-specific guardrails;
- dedicated architecture-test module setup;
- AI-friendly prompts and skills for setup and rule writing.
The result is a lightweight quality gate that lives with the codebase.
When Konture Is a Good Fit
Konture is useful when your architecture rules mention:
- Gradle modules;
- Kotlin packages;
- source sets;
- domain, data, UI, adapter, or infrastructure layers;
- feature module isolation;
- API and implementation module boundaries;
- Kotlin visibility;
- public API shape;
- KMP shared-code portability;
- project-specific coding conventions that require whole-project context.
It is less useful for rules that a compiler or linter already handles well. If the issue is formatting, use a formatter. If the issue is a simple local smell, use a linter. If the issue is behavior, write a unit or integration test.
Konture earns its place when the question is structural.
The Core Idea
Architecture tests should not depend on memory.
They should not depend on a reviewer noticing every boundary violation.
They should not depend on an AI assistant fully absorbing a long prompt.
They should run.
Konture exists to make Kotlin architecture rules executable against the two things Kotlin teams actually use to structure their systems: the Gradle build graph and the Kotlin source code.
In the next article, we will set up Konture and write a practical starter suite of architecture tests.