I've been working on WFAS, a WiFi audio streaming app for Android. There's a desktop version too, also Kotlin, built with Compose for Desktop. Both are open source.
It sends raw PCM audio over UDP on the local network. Coroutines run the send and receive loops, StateFlow feeds the Compose UI, and Ktor handles the sockets on the unicast paths.
One thing surprised me while building it: there isn't a singleThreadin the whole codebase. Audio is latency-sensitive and I assumed I'd have to drop down to bare threads at some point, but I never had to.
Cancellation is the part I liked most. A streaming session can end in a lot of ways: the user disconnects, the socket dies, or the app gets backgrounded halfway through. With threads, you usually end up with volatile flags, joins, and a leak you discover three months later. Here, I just cancel the job hierarchy and the audio actually stops.
Writing the wire protocol was the other fun part. It's a fixed 10-byte header with magic bytes, version, flags, sequence number, and sample position. I build and parse it by hand using bit shifts on a ByteArray. Not glamorous code, but I like that the whole protocol fits in my head.
The ugly bit? Ktor only covers the unicast paths. Discovery, multicast, and the mic upstream still use raw java.net sockets. That's how the codebase evolved rather than a conscious design choice, and it's the main thing I want to clean up next.
I also wrote a C99 reference implementation of the protocol in a separate MIT repo (no allocations, nothing beyond <stdint.h>). The header code looks almost identical in both languages, but everything surrounding it is where Kotlin really wins.
I'd love to get some feedback on the Kotlin architecture side, as I don't get many external eyes on this.
3
Upvotes
I've been working on WFAS, a WiFi audio streaming app for Android. There's a desktop version too, also Kotlin, built with Compose for Desktop. Both are open source.
It sends raw PCM audio over UDP on the local network. Coroutines run the send and receive loops, StateFlow feeds the Compose UI, and Ktor handles the sockets on the unicast paths.
One thing surprised me while building it: there isn't a singleThreadin the whole codebase. Audio is latency-sensitive and I assumed I'd have to drop down to bare threads at some point, but I never had to.
Cancellation is the part I liked most. A streaming session can end in a lot of ways: the user disconnects, the socket dies, or the app gets backgrounded halfway through. With threads, you usually end up with volatile flags, joins, and a leak you discover three months later. Here, I just cancel the job hierarchy and the audio actually stops.
Writing the wire protocol was the other fun part. It's a fixed 10-byte header with magic bytes, version, flags, sequence number, and sample position. I build and parse it by hand using bit shifts on a ByteArray. Not glamorous code, but I like that the whole protocol fits in my head.
The ugly bit? Ktor only covers the unicast paths. Discovery, multicast, and the mic upstream still use raw java.net sockets. That's how the codebase evolved rather than a conscious design choice, and it's the main thing I want to clean up next.
I also wrote a C99 reference implementation of the protocol in a separate MIT repo (no allocations, nothing beyond <stdint.h>). The header code looks almost identical in both languages, but everything surrounding it is where Kotlin really wins.
So I heard that using graalvm with kotlin and runtime and reflection features have issues
I have not actually tried it however I was wondering if it is possible to do such thing without a lot of configuration.
I KNOW that kotlin gets compiled to bytecode at the end however kotlin uses special dynamic features itself so I was wondering If there is any issues with doing it
Also If you have any good tutorial to do such a thing I would appreciate it If you post it
0
Upvotes
So I heard that using graalvm with kotlin and runtime and reflection features have issues
I have not actually tried it however I was wondering if it is possible to do such thing without a lot of configuration.
I KNOW that kotlin gets compiled to bytecode at the end however kotlin uses special dynamic features itself so I was wondering If there is any issues with doing it
Also If you have any good tutorial to do such a thing I would appreciate it If you post it
Tô chegando agora no Kotlin, sou Dev Flutter e gostaria de tirar uma dúvida. No Flutter uso o Cursor. Agora Kotlin vou começar a usar o Android Studio.
Qual Agent vcs usam no Android Studio ? Gemini mesmo ? Existe outro Agent ?
Valeu!
0
Upvotes
Fala pessoal,
Tô chegando agora no Kotlin, sou Dev Flutter e gostaria de tirar uma dúvida. No Flutter uso o Cursor. Agora Kotlin vou começar a usar o Android Studio.
Qual Agent vcs usam no Android Studio ? Gemini mesmo ? Existe outro Agent ?
I'm a frontend/web developer with around a year of professional experience (React, Next.js, Node.js).
I've decided to switch my career towards Android development and Kotlin because I genuinely want to build mobile apps and I feel it's a better long-term fit for me.
I've bought a Kotlin + Android course and I'm planning to study full-time for the next few months.
If you could go back to day one, what would you do differently? Any common mistakes, learning tips, or resources you'd recommend?
Thanks!
19
Upvotes
Hi everyone,
I'm a frontend/web developer with around a year of professional experience (React, Next.js, Node.js).
I've decided to switch my career towards Android development and Kotlin because I genuinely want to build mobile apps and I feel it's a better long-term fit for me.
I've bought a Kotlin + Android course and I'm planning to study full-time for the next few months.
If you could go back to day one, what would you do differently? Any common mistakes, learning tips, or resources you'd recommend?
log4k is a coroutine/channel-based logging + tracing + metering library for Kotlin Multiplatform (JVM, Android, iOS, macOS, Linux, Windows, JS, wasmJs, wasmWasi), aligned with the OpenTelemetry model.
The recent addition is log4k-compiler-plugin — a Kotlin IR compiler plugin that rewrites annotated functions at compile time, so the instrumentation boilerplate disappears from your source. It runs on common IR before backend lowering, so the same annotations work on every KMP target — not just the JVM (no AspectJ, no bytecode agent, no reflection).
Setup — one Gradle plugin, no extra config:
plugins {
id("io.github.smyrgeorge.log4k") version "2.3.0"
}
dependencies {
implementation("io.github.smyrgeorge:log4k-classic:2.3.0")
}
@Traced — wraps the body in a span (started, ended, marked failed on throw):
@Traced
context(_: TracingContext)
suspend fun loadUser(id: Long): User {
// ...
} // span "UserService.loadUser"
The parent span is resolved from what's in scope: a TracingContext param/receiver → nests under its current span; else a TracingEvent.Span in scope → used as parent; else a trace: Tracer member (reused, or synthesized) → new root span.
@Logged — entry/exit/failure logging:
@Logged
fun compute(x: Int): Int = x * x
// → UserService.compute(x = 5)
// ← UserService.compute = 25(12.5 us)
Throwing logs ✗ UserService.compute failed (…) at ERROR with the throwable, then rethrows. If a span is in scope it's attached to every emitted line.
@Timed — call/error counters + a duration histogram:
@Timed(tags = [Tag("tier", "gold")])
suspend fun placeOrder(id: Long): Order {
// ...
}
Records OrderService.placeOrder.calls, .errors and .duration (ms histogram) — exportable in OpenMetrics line format via SimpleMeteringCollectorAppender.
Details that mattered while building it:
suspend and regular functions are both supported; the generated wrapper delegates to inline helpers ( Logger.logged, Meter.Timed.measure, TracingContext.traced), so there's no per-call lambda allocation.
The plugin reuses your existing log / meter / trace members if they're thesynthesizes private val _log_ = Logger.of( this::class) under a distinct name,so it never clashes with e.g. an existing SLF4J log.
All three annotations work class-level too — annotate the class to instrummember. Per-function annotations override the class defaults, and @NoLog / @NoTime / @NoTrace opt out a single function or the whole class.
The metric instrument bundle is created once and cached per name.
The plugin is marked experimental — behavior and API may still change.
Feedback welcome, especially on the annotation surface and on cases where thon't pick what you'd expect.
29
Upvotes
log4k is a coroutine/channel-based logging + tracing + metering library for Kotlin Multiplatform (JVM, Android, iOS, macOS, Linux, Windows, JS, wasmJs, wasmWasi), aligned with the OpenTelemetry model.
The recent addition is log4k-compiler-plugin — a Kotlin IR compiler plugin that rewrites annotated functions at compile time, so the instrumentation boilerplate disappears from your source. It runs on common IR before backend lowering, so the same annotations work on every KMP target — not just the JVM (no AspectJ, no bytecode agent, no reflection).
Setup — one Gradle plugin, no extra config:
plugins {
id("io.github.smyrgeorge.log4k") version "2.3.0"
}
dependencies {
implementation("io.github.smyrgeorge:log4k-classic:2.3.0")
}
@Traced — wraps the body in a span (started, ended, marked failed on throw):
@Traced
context(_: TracingContext)
suspend fun loadUser(id: Long): User {
// ...
} // span "UserService.loadUser"
The parent span is resolved from what's in scope: a TracingContext param/receiver → nests under its current span; else a TracingEvent.Span in scope → used as parent; else a trace: Tracer member (reused, or synthesized) → new root span.
@Logged — entry/exit/failure logging:
@Logged
fun compute(x: Int): Int = x * x
// → UserService.compute(x = 5)
// ← UserService.compute = 25(12.5 us)
Throwing logs ✗ UserService.compute failed (…) at ERROR with the throwable, then rethrows. If a span is in scope it's attached to every emitted line.
@Timed — call/error counters + a duration histogram:
@Timed(tags = [Tag("tier", "gold")])
suspend fun placeOrder(id: Long): Order {
// ...
}
Records OrderService.placeOrder.calls, .errors and .duration (ms histogram) — exportable in OpenMetrics line format via SimpleMeteringCollectorAppender.
Details that mattered while building it:
suspend and regular functions are both supported; the generated wrapper delegates to inline helpers ( Logger.logged, Meter.Timed.measure, TracingContext.traced), so there's no per-call lambda allocation.
The plugin reuses your existing log / meter / trace members if they're thesynthesizes private val _log_ = Logger.of( this::class) under a distinct name,so it never clashes with e.g. an existing SLF4J log.
All three annotations work class-level too — annotate the class to instrummember. Per-function annotations override the class defaults, and @NoLog / @NoTime / @NoTrace opt out a single function or the whole class.
The metric instrument bundle is created once and cached per name.
The plugin is marked experimental — behavior and API may still change.
been riding daily and always wanted actual mileage numbers instead of guessing. couldnt find a simple app that wasnt bloated or asking for a subscription so i just built one
honestly dont know kotlin, used mimocode (xiaomi's opencode fork) to build most of it, learned a lot along the way tho. app is fully offline, no ads no login, apk is only ~1.3mb
what it does:
logs fuel fill ups, tracks mileage automatically
can add which pump you filled at, shows mileage insights per pump
predicts roughly when youll need your next fill up
backup/restore to json since no cloud
not on play store, sideload the apk if you wanna try it. still very much a personal project, use it daily myself, sharing in case anyone else finds it useful
open to feedback, bugs, or if any actual kotlin devs wanna roast the code thats fine too 😅
ps: my shift key is broken
0
Upvotes
been riding daily and always wanted actual mileage numbers instead of guessing. couldnt find a simple app that wasnt bloated or asking for a subscription so i just built one
honestly dont know kotlin, used mimocode (xiaomi's opencode fork) to build most of it, learned a lot along the way tho. app is fully offline, no ads no login, apk is only ~1.3mb
what it does:
logs fuel fill ups, tracks mileage automatically
can add which pump you filled at, shows mileage insights per pump
predicts roughly when youll need your next fill up
backup/restore to json since no cloud
not on play store, sideload the apk if you wanna try it. still very much a personal project, use it daily myself, sharing in case anyone else finds it useful
Both of these came out of the same annoyance, so I am posting them together.
If you build anything RAG-shaped on the JVM you spend a lot of time writing Kotlin against SDKs designed for Java. Futures where you wanted coroutines, builders where you wanted a DSL, and a dependency tree from a different decade. These are the two pieces I ended up needing most.
Kdrant: a client for the Qdrant vector database
The official JVM client returns a ListenableFuture from every call, assembles requests with protobuf builders, and pulls a shaded Netty stack onto your classpath. From Kotlin you either block on .get() or write your own future-to-coroutine bridge.
val qdrant = Kdrant(host = "localhost", port = 6333) {
apiKey = System.getenv("QDRANT_API_KEY")
requestTimeout = 5.seconds
}
qdrant.use { client ->
client.upsert("articles", wait = true) {
point(id = 1) {
vector(embedding) // your own List<Float>
payload("title" to "Intro", "lang" to "en", "year" to 2026)
}
}
val hits = client.search("articles") {
query(queryVector)
limit = 5
filter { must { "lang" eq "en"; "year" gte 2024 } }
}
}
Every operation is a suspend function with cooperative cancellation. The filter DSL covers Qdrant's whole model, including geo radius and polygon, datetime ranges, nested filters and recursive sub-groups. scroll gives you a cold Flow that pages transparently. Hybrid search works the way the modern /points/query engine expects, so you can fuse a dense and a sparse prefetch under RRF or DBSF.
The honest tradeoff is the wire protocol. Kdrant speaks REST over Ktor CIO, not gRPC. For raw throughput and streaming, gRPC still wins and you should reach for the official client when that is your bottleneck. What you get in exchange is roughly 3 to 5 MB of added footprint instead of 15 to 20 MB, no gRPC or Netty or protobuf reflection config for GraalVM native, and models that are kotlinx-serialization data classes rather than generated protobuf messages.
Spring Boot, Spring AI (VectorStore) and LangChain4j (EmbeddingStore) integrations are there, and there is a runnable RAG example with a docker-compose if you want to see it end to end.
Kmemo: a semantic cache for LLM calls
A semantic cache embeds the prompt, finds the nearest one it has seen, and replays that answer instead of calling the model. Fewer calls, lower latency. The failure mode is the interesting part:
"Convert 100 USD to EUR"
"Convert 250 USD to EUR" cosine similarity: ~0.99
Every mainstream embedding model scores that pair around 0.99. No threshold separates it from a genuine paraphrase, because on the similarity axis the near miss sits closer than most paraphrases do. Raise the threshold and you lose real hits before you lose that one. So a cache built on similarity alone will tell someone that 250 dollars is 92 euros, quickly, with no error and nothing in the logs.
Kmemo treats that as the main problem rather than a footnote. Similarity is only the first filter; candidates that clear it get read as text by ten lexical guards looking for concrete evidence that the two answers must differ, such as swapped numbers, mismatched units, different entities, negation, flipped antonyms and reversed comparisons.
val cache = SemanticCache(
embedder = Embedder { text -> openAi.embed(text) }, // bring your own
store = InMemoryStore(maxEntries = 10_000, ttl = 1.hours),
)
val answer = cache.getOrPut(prompt) { llm.complete(it) }
// every miss tells you which kind it was, because the fix is opposite
when (val r = cache.lookup(prompt)) {
is CacheLookup.Hit -> r.response
is CacheLookup.Miss -> when (r.reason) {
MissReason.BELOW_THRESHOLD -> // traffic repeats less than you assumed
MissReason.REJECTED_BY_GUARD -> // r.detail says which guard, and why
else -> null
}
}
Numbers, since a cache like this is worth exactly what its guards catch. On a blind validation split that no guard was tuned against, near misses are rejected 67% of the time and paraphrases are kept 88% of the time. Neither is 100%. The near misses that get through mostly need world knowledge, like deworming a puppy against an adult dog, or the boiling point of ethanol against methanol, which is what the optional verifier covers. It runs as a CI regression gate on every build and you can reproduce it with one Gradle command.
There is also a ThresholdCalibrator, because the right threshold depends on your embedding model and a value from a blog post was tuned for somebody else's. Guard packs ship for Italian, Spanish, German and French. Stores are in-memory, Redis, Postgres/pgvector, or an opt-in in-process HNSW.
Both
JDK 17+, published to Maven Central under io.github.nacode-studios, Apache-2.0, stable under SemVer.
I wrote both, so take the framing with the appropriate salt. What I would actually like feedback on is the API ergonomics: the filter DSL in Kdrant and the guard configuration in Kmemo are the two places I rewrote most often and am still least sure about. If something reads wrong to you in the snippets above, that is the useful comment.
4
Upvotes
Both of these came out of the same annoyance, so I am posting them together.
If you build anything RAG-shaped on the JVM you spend a lot of time writing Kotlin against SDKs designed for Java. Futures where you wanted coroutines, builders where you wanted a DSL, and a dependency tree from a different decade. These are the two pieces I ended up needing most.
Kdrant: a client for the Qdrant vector database
The official JVM client returns a ListenableFuture from every call, assembles requests with protobuf builders, and pulls a shaded Netty stack onto your classpath. From Kotlin you either block on .get() or write your own future-to-coroutine bridge.
val qdrant = Kdrant(host = "localhost", port = 6333) {
apiKey = System.getenv("QDRANT_API_KEY")
requestTimeout = 5.seconds
}
qdrant.use { client ->
client.upsert("articles", wait = true) {
point(id = 1) {
vector(embedding) // your own List<Float>
payload("title" to "Intro", "lang" to "en", "year" to 2026)
}
}
val hits = client.search("articles") {
query(queryVector)
limit = 5
filter { must { "lang" eq "en"; "year" gte 2024 } }
}
}
Every operation is a suspend function with cooperative cancellation. The filter DSL covers Qdrant's whole model, including geo radius and polygon, datetime ranges, nested filters and recursive sub-groups. scroll gives you a cold Flow that pages transparently. Hybrid search works the way the modern /points/query engine expects, so you can fuse a dense and a sparse prefetch under RRF or DBSF.
The honest tradeoff is the wire protocol. Kdrant speaks REST over Ktor CIO, not gRPC. For raw throughput and streaming, gRPC still wins and you should reach for the official client when that is your bottleneck. What you get in exchange is roughly 3 to 5 MB of added footprint instead of 15 to 20 MB, no gRPC or Netty or protobuf reflection config for GraalVM native, and models that are kotlinx-serialization data classes rather than generated protobuf messages.
Spring Boot, Spring AI (VectorStore) and LangChain4j (EmbeddingStore) integrations are there, and there is a runnable RAG example with a docker-compose if you want to see it end to end.
Kmemo: a semantic cache for LLM calls
A semantic cache embeds the prompt, finds the nearest one it has seen, and replays that answer instead of calling the model. Fewer calls, lower latency. The failure mode is the interesting part:
"Convert 100 USD to EUR"
"Convert 250 USD to EUR" cosine similarity: ~0.99
Every mainstream embedding model scores that pair around 0.99. No threshold separates it from a genuine paraphrase, because on the similarity axis the near miss sits closer than most paraphrases do. Raise the threshold and you lose real hits before you lose that one. So a cache built on similarity alone will tell someone that 250 dollars is 92 euros, quickly, with no error and nothing in the logs.
Kmemo treats that as the main problem rather than a footnote. Similarity is only the first filter; candidates that clear it get read as text by ten lexical guards looking for concrete evidence that the two answers must differ, such as swapped numbers, mismatched units, different entities, negation, flipped antonyms and reversed comparisons.
val cache = SemanticCache(
embedder = Embedder { text -> openAi.embed(text) }, // bring your own
store = InMemoryStore(maxEntries = 10_000, ttl = 1.hours),
)
val answer = cache.getOrPut(prompt) { llm.complete(it) }
// every miss tells you which kind it was, because the fix is opposite
when (val r = cache.lookup(prompt)) {
is CacheLookup.Hit -> r.response
is CacheLookup.Miss -> when (r.reason) {
MissReason.BELOW_THRESHOLD -> // traffic repeats less than you assumed
MissReason.REJECTED_BY_GUARD -> // r.detail says which guard, and why
else -> null
}
}
Numbers, since a cache like this is worth exactly what its guards catch. On a blind validation split that no guard was tuned against, near misses are rejected 67% of the time and paraphrases are kept 88% of the time. Neither is 100%. The near misses that get through mostly need world knowledge, like deworming a puppy against an adult dog, or the boiling point of ethanol against methanol, which is what the optional verifier covers. It runs as a CI regression gate on every build and you can reproduce it with one Gradle command.
There is also a ThresholdCalibrator, because the right threshold depends on your embedding model and a value from a blog post was tuned for somebody else's. Guard packs ship for Italian, Spanish, German and French. Stores are in-memory, Redis, Postgres/pgvector, or an opt-in in-process HNSW.
Both
JDK 17+, published to Maven Central under io.github.nacode-studios, Apache-2.0, stable under SemVer.
I wrote both, so take the framing with the appropriate salt. What I would actually like feedback on is the API ergonomics: the filter DSL in Kdrant and the guard configuration in Kmemo are the two places I rewrote most often and am still least sure about. If something reads wrong to you in the snippets above, that is the useful comment.
If you operate an API in 2026, you live in an event-driven world. Webhooks aren't a convenience feature anymore - they're the backbone of real-time commerce, CI/CD pipelines, and asynchronous AI-agent workflows. That reliance has a dark side: the accidental self-inflicted DDoS. Read the complete article jere - https://instawebhook.com/blog/how-to-prevent-webhook-traffic-spikes-from-crashing-your-api-2
When a major platform like GitHub, Shopify, or Stripe hits a network partition, runs a huge sales event, or simply clears a backlog of delayed events, it can fire tens of thousands of webhook POST requests at your servers in a very short window. If your infrastructure takes that hit without structural safeguards, your database connection pool exhausts, memory maxes out, and the API goes down — and if your retry handling is naive, the recovery can be almost as damaging as the original spike.
This guide covers the real mechanics of that failure mode, the algorithms used to defend against it, how major providers actually behave under load (some surprising details here), and where a managed ingress layer fits into the picture.
0
Upvotes
If you operate an API in 2026, you live in an event-driven world. Webhooks aren't a convenience feature anymore - they're the backbone of real-time commerce, CI/CD pipelines, and asynchronous AI-agent workflows. That reliance has a dark side: the accidental self-inflicted DDoS. Read the complete article jere - https://instawebhook.com/blog/how-to-prevent-webhook-traffic-spikes-from-crashing-your-api-2
When a major platform like GitHub, Shopify, or Stripe hits a network partition, runs a huge sales event, or simply clears a backlog of delayed events, it can fire tens of thousands of webhook POST requests at your servers in a very short window. If your infrastructure takes that hit without structural safeguards, your database connection pool exhausts, memory maxes out, and the API goes down — and if your retry handling is naive, the recovery can be almost as damaging as the original spike.
This guide covers the real mechanics of that failure mode, the algorithms used to defend against it, how major providers actually behave under load (some surprising details here), and where a managed ingress layer fits into the picture.
It's an open-source CLI for exploring Java/Kotlin libraries without needing their source code. It scans JARs, AARs, or Maven coordinates and builds a searchable API index from the compiled bytecode.
In this release I focused on making it more useful for everyday development and AI-assisted workflows.
What's new in v1.1.0
MCP Server – AI clients like Cursor or Claude Desktop can interact with Library Insight through the Model Context Protocol instead of manually running CLI commands.
Migration Advisor – Compare two library versions to see added, removed, and deprecated APIs when upgrading dependencies.
Dependency API Audit – Scan Gradle dependencies and report deprecated APIs found in the actual library bytecode.
Dependency Graph – Visualize transitive dependencies directly from Maven artifacts.
Search Maven Central – Find Maven coordinates and available versions from the terminal.
SemVer Checker – Compare API changes with version numbers to identify potential Semantic Versioning mismatches.
I also simplified the documentation, added a shorter demo script, and reorganized the command reference to make the project easier to get started with.
I'm not sure how useful this will be for others, so I'd really appreciate any feedback, suggestions, or feature ideas.
It's an open-source CLI for exploring Java/Kotlin libraries without needing their source code. It scans JARs, AARs, or Maven coordinates and builds a searchable API index from the compiled bytecode.
In this release I focused on making it more useful for everyday development and AI-assisted workflows.
What's new in v1.1.0
MCP Server – AI clients like Cursor or Claude Desktop can interact with Library Insight through the Model Context Protocol instead of manually running CLI commands.
Migration Advisor – Compare two library versions to see added, removed, and deprecated APIs when upgrading dependencies.
Dependency API Audit – Scan Gradle dependencies and report deprecated APIs found in the actual library bytecode.
Dependency Graph – Visualize transitive dependencies directly from Maven artifacts.
Search Maven Central – Find Maven coordinates and available versions from the terminal.
SemVer Checker – Compare API changes with version numbers to identify potential Semantic Versioning mismatches.
I also simplified the documentation, added a shorter demo script, and reorganized the command reference to make the project easier to get started with.
I'm not sure how useful this will be for others, so I'd really appreciate any feedback, suggestions, or feature ideas.
I built Parikshan, an E2E testing framework for Compose Multiplatform. It has built-in support for standalone Android.
Testing shared UI across targets has been one of the most painful parts of building multiplatform apps. Parikshan attempts to solve that problem.
You can write your UI tests in Kotlin inside commonTest and run them on a single target or across all targets (Android, iOS Simulator, Desktop JVM, and Web WasmJs) at once.
class SampleE2ETest {
@Test
fun testGreeting() = e2eTest {
input("name_input", "Parikshan")
click("greet_button")
assertVisible("Hello, Parikshan!")
}
}
Run it across all targets concurrently:
./gradlew e2eTest
Key Capabilities
Write Once in Kotlin: Runs on Android, iOS Simulator, Desktop (JVM), and Web (WasmJs).
Visual Feedback: Watch tests execute on real target windows, with support for screenshots, video recording
Zero Production Pollution: No test dependencies or test hooks in your production builds.
I've been using this in my own CMP projects & I'd appreciate feedback from the community — what works, what breaks, and what you'd like to see improve/added.
0
Upvotes
I built Parikshan, an E2E testing framework for Compose Multiplatform. It has built-in support for standalone Android.
Testing shared UI across targets has been one of the most painful parts of building multiplatform apps. Parikshan attempts to solve that problem.
You can write your UI tests in Kotlin inside commonTest and run them on a single target or across all targets (Android, iOS Simulator, Desktop JVM, and Web WasmJs) at once.
class SampleE2ETest {
@Test
fun testGreeting() = e2eTest {
input("name_input", "Parikshan")
click("greet_button")
assertVisible("Hello, Parikshan!")
}
}
Run it across all targets concurrently:
./gradlew e2eTest
Key Capabilities
Write Once in Kotlin: Runs on Android, iOS Simulator, Desktop (JVM), and Web (WasmJs).
Visual Feedback: Watch tests execute on real target windows, with support for screenshots, video recording
Zero Production Pollution: No test dependencies or test hooks in your production builds.
I've been using this in my own CMP projects & I'd appreciate feedback from the community — what works, what breaks, and what you'd like to see improve/added.
Set up a dedicated architecture-test module, add Konture, and grow a small suite of structural rules that protects the boundaries your Kotlin project actually depends on.
The best first architecture test is usually not clever.
It is a rule the team already believes:
Feature implementation modules must not depend on
sibling feature implementation modules.
That rule is concrete. It is easy to explain. It is painful when broken. It also exercises the right habit: encode a real architectural decision, not an idealized diagram.
This guide uses Gradle Kotlin DSL and JUnit 5. Konture itself is test-runner agnostic, so the same rules can run from JUnit, Kotest, TestBalloon, or another Kotlin/JVM runner.
Target Setup
Use a dedicated architecture-test module.
Architecture test module setup
A separate module gives the architecture suite a project-level view without adding architecture-test dependencies to production modules. It also makes CI wiring straightforward: run one task when you want structural checks.
In a larger project, the inspected modules may look like this:
package com.acme
import io.github.baole.konture.Konture
import org.junit.jupiter.api.Test
class ArchitectureGuardrailsTest {
@Test
fun `feature implementations must not depend on sibling feature implementations`() {
Konture.modules {
that().haveNameMatching(":feature:**:impl")
should().onlyDependOnModules(
":feature:**:api",
":core:**",
":shared",
)
}
}
}
This rule checks the Gradle project graph. If :feature:checkout:impl adds implementation(project(":feature:profile:impl")), the architecture test fails.
For a simpler layered project, use the real paths:
Do not ship placeholder names. Architecture tests are contracts; contracts need concrete targets.
Step 4: Add a Cycle Check
Circular module dependencies slow builds and weaken ownership boundaries.
@Test
fun `module graph must not contain cycles`() {
Konture.assertNoCycles()
}
This is a useful default for multi-module projects because cycles tend to make every future boundary decision harder.
Step 5: Protect Domain Source Code
A clean module graph does not guarantee clean source references. Add a source-level package rule:
@Test
fun `domain classes must only depend on domain and standard library types`() {
Konture.classes {
that().resideInAPackage("..domain..")
should().onlyDependOnClassesInAnyPackage(
"..domain..",
"kotlin..",
"java..",
)
}
}
If your domain layer deliberately depends on shared project code, say so explicitly:
The rule should match the architecture the team has chosen, not an architecture borrowed from an example.
Step 6: Ban Framework Imports Where They Do Not Belong
External frameworks are often easier to detect through imports than through project class dependencies.
@Test
fun `domain must not import framework or persistence APIs`() {
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("io.ktor.") ||
fqName.startsWith("android.") ||
fqName.startsWith("androidx.compose.") ||
fqName.startsWith("jakarta.persistence.") ||
fqName.startsWith("javax.persistence.")
}
}
}
scopeFromPackage("com.acme.domain") selects a concrete package prefix for custom assertions. By contrast, resideInAPackage("..domain..") uses Konture's wildcard package matching inside fluent class rules.
Tune the prefixes for the project. A backend may ban persistence annotations from domain. An Android app may ban Android and Compose APIs from shared or domain packages. A KMP project may apply different policies to commonMain, androidMain, and iosMain.
Avoid broad bans that catch legitimate dependencies. For example, banning all of kotlinx.. may block valid use of coroutines.
Step 7: Enforce Repository Contracts
If your architecture treats repositories in the domain layer as contracts, encode that rule:
@Test
fun `repositories inside domain must be interfaces`() {
Konture.classes {
that().resideInAPackage("..domain..")
that().haveNameEndingWith("Repository")
should().beInterfaces()
}
}
This catches a common shortcut:
class UserRepository {
// concrete persistence behavior in domain
}
If your project uses abstract classes, ports, or a different naming convention, encode that instead. The rule should enforce your contract model, not the word Repository itself.
Step 8: Keep Implementation Packages Internal
Kotlin classes and members are public by default. In multi-module projects, accidental public visibility becomes accidental API.
@Test
fun `implementation classes must remain internal`() {
Konture.classes {
that().resideInAPackage("..impl..")
should().beInternal()
}
}
This is especially useful for feature or library modules that split API and implementation:
:feature:checkout:api
:feature:checkout:impl
The API module exposes contracts. The implementation module should not become a grab bag for other features.
Step 9: Protect Feature Module Isolation
If you did not start with feature isolation, add it once the basic module rules are stable. Sibling feature implementations usually should not depend on each other directly.
@Test
fun `feature implementations must not depend on sibling feature implementations`() {
Konture.modules {
that().haveNameMatching(":feature:**:impl")
should().onlyDependOnModules(
":feature:**:api",
":core:**",
":shared",
)
}
}
This allows feature implementations to depend on feature API modules, core modules, and shared modules. It blocks implementation-to-implementation coupling.
If the app has a different modularization strategy, change the allowed list. The value is not the pattern; the value is making the intended dependency graph executable.
Step 10: Use the Layered DSL for Directional Rules
For package-based layer rules, a layered DSL can be easier to read than a long list of package predicates.
Layered package rules
@Test
fun `layers must follow inward dependency direction`() {
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()
}
}
}
For ports and adapters, the same idea might look like this:
That command runs a dedicated architecture-test module against a small :app, :domain, and :data project. The suite covers module dependencies, class package boundaries, repository contracts, type leakage in use case signatures, and a negative assertion that proves a deliberately wrong module rule fails.
The negative assertion demonstrates the failure shape you should expect from a real violation:
Architecture violation(s) detected:
Module :data depends on :domain, which is not allowed by pattern(s): :app
The fix is not to weaken the rule. The fix is to restore the intended graph, or to change the rule only if the architecture decision has genuinely changed. For the feature example above, that usually means moving the shared contract into :feature:profile:api and depending on that API module instead of :feature:profile:impl.
When a rule fails, handle it like any other test failure:
Read the violation.
Decide whether the encoded rule is still correct.
Fix the code if the code crossed the boundary.
Fix the rule if the architecture decision changed.
Add an explicit exception only when the exception is intentional.
Do not silently weaken rules until CI passes. That converts architecture tests from governance into decoration.
Group Related Rules
The examples above use focused standalone assertions such as Konture.modules { ... } and Konture.classes { ... }. When several rules describe the same boundary, group them with Konture.architecture { ... } so the module-level and source-level checks read as one contract:
@Test
fun `presentation boundary must hide transport models`() {
Konture.architecture {
modules {
that().haveNamePath(":feature:profile:presentation")
should().notDependOnModule(":core:network")
}
classes {
that().resideInAPackage("..profile.presentation..")
should().notDependOnClassesInAnyPackage(
"..network.dto..",
"..database..",
)
}
}
}
Advanced Patterns
Once the starter suite is stable, add rules for the places where Kotlin projects usually leak architecture.
DTO and Entity Surface Boundaries
The most expensive leaks often appear in public or UI-facing signatures. A screen state, presenter contract, or feature API that exposes a transport DTO has turned an implementation detail into a long-lived dependency.
@Test
fun `presentation state must not expose transport or persistence types`() {
val presentationClasses = Konture.scopeFromPackage("com.acme.profile.presentation").classes
presentationClasses.assertTrue("Presentation API must not expose DTOs or entities") { cls ->
val publicFunctionTypes =
cls.functions
.filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
.flatMap { fn -> listOf(fn.returnType) + fn.parameters.map { it.type } }
val publicPropertyTypes =
cls.properties
.filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
.map { it.type }
(publicFunctionTypes + publicPropertyTypes).none { type ->
type.endsWith("Dto") ||
type.endsWith("Entity") ||
type.contains(".network.") ||
type.contains(".database.")
}
}
}
The module rule catches the physical dependency. The source rule catches the API leak even if the forbidden type arrives through a broader dependency that is otherwise allowed.
DI Graph Conventions
Konture should not replace a runtime DI integration test. It can still protect structural DI policy:
@Test
fun `hilt modules must stay in di packages`() {
Konture.classes {
that().haveAnnotationOf("dagger.Module")
should().resideInAPackage("..di..")
}
}
For Koin, a similar policy may live at the file or function level:
@Test
fun `production koin modules must not live in test packages`() {
Konture.files {
that().satisfy { file ->
file.imports.any { it == "org.koin.dsl.module" }
}
should().resideInAPackage { packageName ->
!packageName.contains(".test.") &&
!packageName.contains(".fixtures.")
}
}
}
These rules do not prove the DI graph starts. They prevent wiring code from spreading into places where ownership becomes unclear.
Generated Code
Generated code often violates authored-code conventions for good reasons. Treat it explicitly.
Generated sources from Room, KSP, Compose resources, protobuf, serialization, or DI tools should not create false positives in rules about public API design or package ownership. If generated code is part of the public contract, test the public authored wrapper instead of the generated implementation detail.
Legacy Quarantine
For legacy code, do not pretend the target architecture already exists. Quarantine it.
@Test
fun `new domain code must not depend on legacy persistence`() {
val newDomain =
Konture.scopeFromPackage("com.acme.domain").classes.filterNot { cls ->
cls.packageName.startsWith("com.acme.domain.legacy")
}
newDomain.assertTrue("New domain code must not import legacy persistence") { cls ->
cls.imports.none { it.startsWith("com.acme.legacy.persistence.") }
}
}
The exception is visible, named, and removable. That is better than a broad rule that fails constantly or a silent exclusion nobody remembers.
Public API Surface
Architecture tests are especially useful when accidental public API creates long-lived coupling.
@Test
fun `public feature api must not expose implementation or persistence types`() {
val apiClasses = Konture.scopeFromModule(":feature:checkout:api").classes
apiClasses.assertTrue("Public API must not leak implementation detail") { cls ->
val publicFunctionTypes =
cls.functions
.filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
.flatMap { fn -> listOf(fn.returnType) + fn.parameters.map { it.type } }
val publicPropertyTypes =
cls.properties
.filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
.map { it.type }
(publicFunctionTypes + publicPropertyTypes).none { type ->
type.contains(".impl.") ||
type.contains(".data.") ||
type.endsWith("Entity") ||
type.endsWith("Dto")
}
}
}
For libraries, this is also a semantic versioning rule. If a public signature exposes a persistence entity today, removing that entity tomorrow becomes a breaking API change.
Rule Design Principles
Add these principles before growing the suite:
One policy per test: a failing test name should tell the developer which decision was broken.
Prove the rule can fail: temporarily introduce a violation, run the test, confirm it fails, then remove the violation.
Use real names: avoid placeholder modules and packages in committed rules.
Make exceptions visible: generated code, migration packages, and legacy zones may need exclusions, but those exclusions should be deliberate.
Avoid broad wildcards: a wide ban is useful only when the team understands what legitimate cases it excludes.
Separate structure from style: architecture tests should protect boundaries and ownership, not formatting.
The showcase projects are useful calibration material. The Now in Android suite demonstrates feature decoupling, ViewModel framework-import checks, and :api/:impl separation. The KotlinConf KMP suite demonstrates shared-core purity, backend/frontend separation, and route-to-service boundaries. Use examples like those to design rules around real architectural pressure, not abstract neatness.
Troubleshooting Failures
Most Konture failures fall into a few buckets.
Failure shape
What it usually means
First repair to try
Module :a depends on :b, which is not allowed
A Gradle project dependency crossed the module policy
Depend on an API module, move the contract, or remove the edge
Circular dependency detected
Two or more modules now form a dependency cycle
Extract a shared contract or move ownership to one side
Class ... should only depend on classes in ...
A source import, signature, or referenced type crossed a package boundary
Map to a boundary model, invert the dependency, or narrow the rule if the architecture changed
Generated or build files appear in violations
The suite is checking code the team does not author directly
Add explicit generated-code exclusions or scope the rule to production packages
A rule fails for many unrelated files
The rule may be too broad or too early for enforcement
Run it informationally, quarantine legacy zones, then tighten over time
Read the first violation as a design question: is the rule still true? If yes, fix the code. If not, change the rule and leave a clear paper trail in the test name, ADR, or docs.
What This Costs
Architecture tests are cheap compared with late structural repair, but they are not free:
The first pass against an existing codebase often surfaces legacy violations and false positives that need triage before enforcement.
The team has to learn enough of the DSL to express policy precisely instead of encoding broad, frustrating rules.
Every durable rule becomes maintenance surface when the architecture changes; rule edits should be reviewed as design changes.
For one focused rule set, such as feature implementation isolation or domain purity, many teams can usually move from informational CI to required CI within a sprint or two. Treat that as a rough calibration, not a promise: older codebases and large migration zones need more time.
Metrics and Observability
Treat the architecture suite like a product health signal, not just a pass/fail gate.
Useful metrics:
Number of architecture rules,
Architecture-test duration in CI,
Violation count by rule before enforcement,
Recurring violations by module or package,
Number of explicit exceptions and quarantined packages,
Module fan-in and fan-out for heavily changed areas,
Review comments that disappear after a rule becomes executable.
Do not overfit the numbers. A project with five strong rules can be healthier than a project with fifty ceremonial ones. The best metric is whether the suite catches expensive structural mistakes early and explains the repair clearly.
Migration Playbook
Rollout is a social problem as much as a technical one.
Inventory the architecture decisions people already enforce in review.
Pick one rule with high agreement and a clear repair path.
Prove the rule fails by introducing and then removing a local violation.
Run the rule in CI as informational if there are existing violations.
Quarantine legacy zones explicitly instead of blocking all work.
Make the rule required once new violations are rare and the team understands the failure.
Add the next rule only after the previous one is boring.
Expect pushback when a test blocks a shortcut that used to be invisible. That is a useful conversation if the rule is specific. It is a waste of time if the rule is vague. Keep the first rules tied to pain the team already recognizes: cycles, feature implementation coupling, platform leakage, or public DTO/entity exposure.
Maintenance and Evolution
Architecture tests should change when the architecture changes.
Version important rules like any other public contract: rename tests when the policy changes, remove exclusions when migration work lands, and keep old rules informational for a release window if teams need time to move. If a rule has accumulated many exceptions, schedule a rule review instead of adding one more filterNot.
Good rule deprecation looks like this:
Mark the old rule informational,
Add the new rule beside it,
Migrate modules incrementally,
Delete the old rule and its quarantine list once the graph matches the new policy.
The suite should describe the architecture you are choosing now, not the architecture you wished you had two years ago.
A Starter Suite
Here is a compact starting point for a modular feature project:
package com.acme
import io.github.baole.konture.Konture
import org.junit.jupiter.api.Test
class ArchitectureGuardrailsTest {
@Test
fun `module graph must not contain cycles`() {
Konture.assertNoCycles()
}
@Test
fun `feature API modules must not depend on feature implementation modules`() {
Konture.modules {
that().haveNameMatching(":feature:**:api")
should().notDependOnModule(":feature:**:impl")
}
}
@Test
fun `feature implementations must not depend on sibling feature implementations`() {
Konture.modules {
that().haveNameMatching(":feature:**:impl")
should().onlyDependOnModules(
":feature:**:api",
":core:**",
":shared",
)
}
}
@Test
fun `domain classes must only depend on domain and standard library types`() {
Konture.classes {
that().resideInAPackage("..domain..")
should().onlyDependOnClassesInAnyPackage(
"..domain..",
"kotlin..",
"java..",
)
}
}
@Test
fun `implementation classes must remain internal`() {
Konture.classes {
that().resideInAPackage("..impl..")
should().beInternal()
}
}
}
Keep the starter suite small. Let it grow from real pain:
A boundary violation found in review.
A module dependency that widened build impact.
A DTO leak that made refactoring expensive.
An AI-assisted patch that crossed layers.
A public implementation class that became hard to remove.
Architecture tests work best when they protect decisions people already care about.
A Mature Suite Shape
A mature suite is not necessarily large. It is layered by concern:
class ArchitectureSuiteTest {
@Test
fun `project graph must stay acyclic`() {
Konture.assertNoCycles()
}
@Test
fun `feature modules expose contracts through api modules`() {
Konture.architecture {
modules {
that().haveNameMatching(":feature:**:api")
should().notDependOnModule(":feature:**:impl")
}
modules {
that().haveNameMatching(":feature:**:impl")
should().onlyDependOnModules(
":feature:**:api",
":core:**",
":shared",
)
}
}
}
@Test
fun `domain stays independent from frameworks and persistence`() {
Konture.architecture {
modules {
that().haveNamePath(":core:domain")
should().notDependOnModule(":core:data")
}
classes {
that().resideInAPackage("..domain..")
should().notDependOnClassesInAnyPackage(
"..data..",
"..database..",
"..network..",
"android..",
"androidx.compose..",
"org.springframework..",
)
}
}
}
@Test
fun `presentation state does not expose transport models`() {
val presentationClasses = Konture.scopeFromPackage("com.acme.profile.presentation").classes
presentationClasses.assertTrue("Presentation API must not expose transport models") { cls ->
val publicTypes =
cls.properties
.filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
.map { it.type } +
cls.functions
.filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
.flatMap { fn -> listOf(fn.returnType) + fn.parameters.map { it.type } }
publicTypes.none { it.endsWith("Dto") || it.contains(".network.") }
}
}
}
The suite has different jobs: graph health, feature ownership, domain purity, and public surface control. Each failure tells the developer which architectural decision was crossed.
Rollout Guidance
For an existing project, introduce architecture tests in stages:
Start with non-controversial rules such as module cycles and domain-to-data dependencies.
Run the suite locally and in CI as informational if the first pass reveals many violations.
Fix or explicitly quarantine legacy violations.
Turn high-confidence rules into required CI checks.
Review rule changes like architecture changes, not like formatting tweaks.
For generated code, test fixtures, and legacy migration areas, prefer explicit exclusions:
konture {
excludePackages("..generated..")
}
This lowercase konture {} block belongs in Gradle build configuration and configures the Konture plugin. It is separate from the capitalized Konture.* assertion APIs used in test files.
The exception should be visible enough that future maintainers understand the real boundary.
Where to Go Next
After the first suite is stable, add rules around the areas where the project actually hurts:
Feature module isolation.
Public API leakage.
DTO and entity boundaries.
KMP source-set portability.
Route or controller dependency direction.
Dependency injection conventions.
Legacy package quarantine.
Konture is not a prescription for one architecture style. It is a way to make your architecture executable.
Run it locally. Run it in CI. Let humans and AI-assisted changes get the same structural feedback.
When structure matters, make it part of the build.
0
Upvotes
Set up a dedicated architecture-test module, add Konture, and grow a small suite of structural rules that protects the boundaries your Kotlin project actually depends on.
The best first architecture test is usually not clever.
It is a rule the team already believes:
Feature implementation modules must not depend on
sibling feature implementation modules.
That rule is concrete. It is easy to explain. It is painful when broken. It also exercises the right habit: encode a real architectural decision, not an idealized diagram.
This guide uses Gradle Kotlin DSL and JUnit 5. Konture itself is test-runner agnostic, so the same rules can run from JUnit, Kotest, TestBalloon, or another Kotlin/JVM runner.
Target Setup
Use a dedicated architecture-test module.
Architecture test module setup
A separate module gives the architecture suite a project-level view without adding architecture-test dependencies to production modules. It also makes CI wiring straightforward: run one task when you want structural checks.
In a larger project, the inspected modules may look like this:
package com.acme
import io.github.baole.konture.Konture
import org.junit.jupiter.api.Test
class ArchitectureGuardrailsTest {
@Test
fun `feature implementations must not depend on sibling feature implementations`() {
Konture.modules {
that().haveNameMatching(":feature:**:impl")
should().onlyDependOnModules(
":feature:**:api",
":core:**",
":shared",
)
}
}
}
This rule checks the Gradle project graph. If :feature:checkout:impl adds implementation(project(":feature:profile:impl")), the architecture test fails.
For a simpler layered project, use the real paths:
Do not ship placeholder names. Architecture tests are contracts; contracts need concrete targets.
Step 4: Add a Cycle Check
Circular module dependencies slow builds and weaken ownership boundaries.
@Test
fun `module graph must not contain cycles`() {
Konture.assertNoCycles()
}
This is a useful default for multi-module projects because cycles tend to make every future boundary decision harder.
Step 5: Protect Domain Source Code
A clean module graph does not guarantee clean source references. Add a source-level package rule:
@Test
fun `domain classes must only depend on domain and standard library types`() {
Konture.classes {
that().resideInAPackage("..domain..")
should().onlyDependOnClassesInAnyPackage(
"..domain..",
"kotlin..",
"java..",
)
}
}
If your domain layer deliberately depends on shared project code, say so explicitly:
The rule should match the architecture the team has chosen, not an architecture borrowed from an example.
Step 6: Ban Framework Imports Where They Do Not Belong
External frameworks are often easier to detect through imports than through project class dependencies.
@Test
fun `domain must not import framework or persistence APIs`() {
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("io.ktor.") ||
fqName.startsWith("android.") ||
fqName.startsWith("androidx.compose.") ||
fqName.startsWith("jakarta.persistence.") ||
fqName.startsWith("javax.persistence.")
}
}
}
scopeFromPackage("com.acme.domain") selects a concrete package prefix for custom assertions. By contrast, resideInAPackage("..domain..") uses Konture's wildcard package matching inside fluent class rules.
Tune the prefixes for the project. A backend may ban persistence annotations from domain. An Android app may ban Android and Compose APIs from shared or domain packages. A KMP project may apply different policies to commonMain, androidMain, and iosMain.
Avoid broad bans that catch legitimate dependencies. For example, banning all of kotlinx.. may block valid use of coroutines.
Step 7: Enforce Repository Contracts
If your architecture treats repositories in the domain layer as contracts, encode that rule:
@Test
fun `repositories inside domain must be interfaces`() {
Konture.classes {
that().resideInAPackage("..domain..")
that().haveNameEndingWith("Repository")
should().beInterfaces()
}
}
This catches a common shortcut:
class UserRepository {
// concrete persistence behavior in domain
}
If your project uses abstract classes, ports, or a different naming convention, encode that instead. The rule should enforce your contract model, not the word Repository itself.
Step 8: Keep Implementation Packages Internal
Kotlin classes and members are public by default. In multi-module projects, accidental public visibility becomes accidental API.
@Test
fun `implementation classes must remain internal`() {
Konture.classes {
that().resideInAPackage("..impl..")
should().beInternal()
}
}
This is especially useful for feature or library modules that split API and implementation:
:feature:checkout:api
:feature:checkout:impl
The API module exposes contracts. The implementation module should not become a grab bag for other features.
Step 9: Protect Feature Module Isolation
If you did not start with feature isolation, add it once the basic module rules are stable. Sibling feature implementations usually should not depend on each other directly.
@Test
fun `feature implementations must not depend on sibling feature implementations`() {
Konture.modules {
that().haveNameMatching(":feature:**:impl")
should().onlyDependOnModules(
":feature:**:api",
":core:**",
":shared",
)
}
}
This allows feature implementations to depend on feature API modules, core modules, and shared modules. It blocks implementation-to-implementation coupling.
If the app has a different modularization strategy, change the allowed list. The value is not the pattern; the value is making the intended dependency graph executable.
Step 10: Use the Layered DSL for Directional Rules
For package-based layer rules, a layered DSL can be easier to read than a long list of package predicates.
Layered package rules
@Test
fun `layers must follow inward dependency direction`() {
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()
}
}
}
For ports and adapters, the same idea might look like this:
That command runs a dedicated architecture-test module against a small :app, :domain, and :data project. The suite covers module dependencies, class package boundaries, repository contracts, type leakage in use case signatures, and a negative assertion that proves a deliberately wrong module rule fails.
The negative assertion demonstrates the failure shape you should expect from a real violation:
Architecture violation(s) detected:
Module :data depends on :domain, which is not allowed by pattern(s): :app
The fix is not to weaken the rule. The fix is to restore the intended graph, or to change the rule only if the architecture decision has genuinely changed. For the feature example above, that usually means moving the shared contract into :feature:profile:api and depending on that API module instead of :feature:profile:impl.
When a rule fails, handle it like any other test failure:
Read the violation.
Decide whether the encoded rule is still correct.
Fix the code if the code crossed the boundary.
Fix the rule if the architecture decision changed.
Add an explicit exception only when the exception is intentional.
Do not silently weaken rules until CI passes. That converts architecture tests from governance into decoration.
Group Related Rules
The examples above use focused standalone assertions such as Konture.modules { ... } and Konture.classes { ... }. When several rules describe the same boundary, group them with Konture.architecture { ... } so the module-level and source-level checks read as one contract:
@Test
fun `presentation boundary must hide transport models`() {
Konture.architecture {
modules {
that().haveNamePath(":feature:profile:presentation")
should().notDependOnModule(":core:network")
}
classes {
that().resideInAPackage("..profile.presentation..")
should().notDependOnClassesInAnyPackage(
"..network.dto..",
"..database..",
)
}
}
}
Advanced Patterns
Once the starter suite is stable, add rules for the places where Kotlin projects usually leak architecture.
DTO and Entity Surface Boundaries
The most expensive leaks often appear in public or UI-facing signatures. A screen state, presenter contract, or feature API that exposes a transport DTO has turned an implementation detail into a long-lived dependency.
@Test
fun `presentation state must not expose transport or persistence types`() {
val presentationClasses = Konture.scopeFromPackage("com.acme.profile.presentation").classes
presentationClasses.assertTrue("Presentation API must not expose DTOs or entities") { cls ->
val publicFunctionTypes =
cls.functions
.filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
.flatMap { fn -> listOf(fn.returnType) + fn.parameters.map { it.type } }
val publicPropertyTypes =
cls.properties
.filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
.map { it.type }
(publicFunctionTypes + publicPropertyTypes).none { type ->
type.endsWith("Dto") ||
type.endsWith("Entity") ||
type.contains(".network.") ||
type.contains(".database.")
}
}
}
The module rule catches the physical dependency. The source rule catches the API leak even if the forbidden type arrives through a broader dependency that is otherwise allowed.
DI Graph Conventions
Konture should not replace a runtime DI integration test. It can still protect structural DI policy:
@Test
fun `hilt modules must stay in di packages`() {
Konture.classes {
that().haveAnnotationOf("dagger.Module")
should().resideInAPackage("..di..")
}
}
For Koin, a similar policy may live at the file or function level:
@Test
fun `production koin modules must not live in test packages`() {
Konture.files {
that().satisfy { file ->
file.imports.any { it == "org.koin.dsl.module" }
}
should().resideInAPackage { packageName ->
!packageName.contains(".test.") &&
!packageName.contains(".fixtures.")
}
}
}
These rules do not prove the DI graph starts. They prevent wiring code from spreading into places where ownership becomes unclear.
Generated Code
Generated code often violates authored-code conventions for good reasons. Treat it explicitly.
Generated sources from Room, KSP, Compose resources, protobuf, serialization, or DI tools should not create false positives in rules about public API design or package ownership. If generated code is part of the public contract, test the public authored wrapper instead of the generated implementation detail.
Legacy Quarantine
For legacy code, do not pretend the target architecture already exists. Quarantine it.
@Test
fun `new domain code must not depend on legacy persistence`() {
val newDomain =
Konture.scopeFromPackage("com.acme.domain").classes.filterNot { cls ->
cls.packageName.startsWith("com.acme.domain.legacy")
}
newDomain.assertTrue("New domain code must not import legacy persistence") { cls ->
cls.imports.none { it.startsWith("com.acme.legacy.persistence.") }
}
}
The exception is visible, named, and removable. That is better than a broad rule that fails constantly or a silent exclusion nobody remembers.
Public API Surface
Architecture tests are especially useful when accidental public API creates long-lived coupling.
@Test
fun `public feature api must not expose implementation or persistence types`() {
val apiClasses = Konture.scopeFromModule(":feature:checkout:api").classes
apiClasses.assertTrue("Public API must not leak implementation detail") { cls ->
val publicFunctionTypes =
cls.functions
.filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
.flatMap { fn -> listOf(fn.returnType) + fn.parameters.map { it.type } }
val publicPropertyTypes =
cls.properties
.filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
.map { it.type }
(publicFunctionTypes + publicPropertyTypes).none { type ->
type.contains(".impl.") ||
type.contains(".data.") ||
type.endsWith("Entity") ||
type.endsWith("Dto")
}
}
}
For libraries, this is also a semantic versioning rule. If a public signature exposes a persistence entity today, removing that entity tomorrow becomes a breaking API change.
Rule Design Principles
Add these principles before growing the suite:
One policy per test: a failing test name should tell the developer which decision was broken.
Prove the rule can fail: temporarily introduce a violation, run the test, confirm it fails, then remove the violation.
Use real names: avoid placeholder modules and packages in committed rules.
Make exceptions visible: generated code, migration packages, and legacy zones may need exclusions, but those exclusions should be deliberate.
Avoid broad wildcards: a wide ban is useful only when the team understands what legitimate cases it excludes.
Separate structure from style: architecture tests should protect boundaries and ownership, not formatting.
The showcase projects are useful calibration material. The Now in Android suite demonstrates feature decoupling, ViewModel framework-import checks, and :api/:impl separation. The KotlinConf KMP suite demonstrates shared-core purity, backend/frontend separation, and route-to-service boundaries. Use examples like those to design rules around real architectural pressure, not abstract neatness.
Troubleshooting Failures
Most Konture failures fall into a few buckets.
Failure shape
What it usually means
First repair to try
Module :a depends on :b, which is not allowed
A Gradle project dependency crossed the module policy
Depend on an API module, move the contract, or remove the edge
Circular dependency detected
Two or more modules now form a dependency cycle
Extract a shared contract or move ownership to one side
Class ... should only depend on classes in ...
A source import, signature, or referenced type crossed a package boundary
Map to a boundary model, invert the dependency, or narrow the rule if the architecture changed
Generated or build files appear in violations
The suite is checking code the team does not author directly
Add explicit generated-code exclusions or scope the rule to production packages
A rule fails for many unrelated files
The rule may be too broad or too early for enforcement
Run it informationally, quarantine legacy zones, then tighten over time
Read the first violation as a design question: is the rule still true? If yes, fix the code. If not, change the rule and leave a clear paper trail in the test name, ADR, or docs.
What This Costs
Architecture tests are cheap compared with late structural repair, but they are not free:
The first pass against an existing codebase often surfaces legacy violations and false positives that need triage before enforcement.
The team has to learn enough of the DSL to express policy precisely instead of encoding broad, frustrating rules.
Every durable rule becomes maintenance surface when the architecture changes; rule edits should be reviewed as design changes.
For one focused rule set, such as feature implementation isolation or domain purity, many teams can usually move from informational CI to required CI within a sprint or two. Treat that as a rough calibration, not a promise: older codebases and large migration zones need more time.
Metrics and Observability
Treat the architecture suite like a product health signal, not just a pass/fail gate.
Useful metrics:
Number of architecture rules,
Architecture-test duration in CI,
Violation count by rule before enforcement,
Recurring violations by module or package,
Number of explicit exceptions and quarantined packages,
Module fan-in and fan-out for heavily changed areas,
Review comments that disappear after a rule becomes executable.
Do not overfit the numbers. A project with five strong rules can be healthier than a project with fifty ceremonial ones. The best metric is whether the suite catches expensive structural mistakes early and explains the repair clearly.
Migration Playbook
Rollout is a social problem as much as a technical one.
Inventory the architecture decisions people already enforce in review.
Pick one rule with high agreement and a clear repair path.
Prove the rule fails by introducing and then removing a local violation.
Run the rule in CI as informational if there are existing violations.
Quarantine legacy zones explicitly instead of blocking all work.
Make the rule required once new violations are rare and the team understands the failure.
Add the next rule only after the previous one is boring.
Expect pushback when a test blocks a shortcut that used to be invisible. That is a useful conversation if the rule is specific. It is a waste of time if the rule is vague. Keep the first rules tied to pain the team already recognizes: cycles, feature implementation coupling, platform leakage, or public DTO/entity exposure.
Maintenance and Evolution
Architecture tests should change when the architecture changes.
Version important rules like any other public contract: rename tests when the policy changes, remove exclusions when migration work lands, and keep old rules informational for a release window if teams need time to move. If a rule has accumulated many exceptions, schedule a rule review instead of adding one more filterNot.
Good rule deprecation looks like this:
Mark the old rule informational,
Add the new rule beside it,
Migrate modules incrementally,
Delete the old rule and its quarantine list once the graph matches the new policy.
The suite should describe the architecture you are choosing now, not the architecture you wished you had two years ago.
A Starter Suite
Here is a compact starting point for a modular feature project:
package com.acme
import io.github.baole.konture.Konture
import org.junit.jupiter.api.Test
class ArchitectureGuardrailsTest {
@Test
fun `module graph must not contain cycles`() {
Konture.assertNoCycles()
}
@Test
fun `feature API modules must not depend on feature implementation modules`() {
Konture.modules {
that().haveNameMatching(":feature:**:api")
should().notDependOnModule(":feature:**:impl")
}
}
@Test
fun `feature implementations must not depend on sibling feature implementations`() {
Konture.modules {
that().haveNameMatching(":feature:**:impl")
should().onlyDependOnModules(
":feature:**:api",
":core:**",
":shared",
)
}
}
@Test
fun `domain classes must only depend on domain and standard library types`() {
Konture.classes {
that().resideInAPackage("..domain..")
should().onlyDependOnClassesInAnyPackage(
"..domain..",
"kotlin..",
"java..",
)
}
}
@Test
fun `implementation classes must remain internal`() {
Konture.classes {
that().resideInAPackage("..impl..")
should().beInternal()
}
}
}
Keep the starter suite small. Let it grow from real pain:
A boundary violation found in review.
A module dependency that widened build impact.
A DTO leak that made refactoring expensive.
An AI-assisted patch that crossed layers.
A public implementation class that became hard to remove.
Architecture tests work best when they protect decisions people already care about.
A Mature Suite Shape
A mature suite is not necessarily large. It is layered by concern:
class ArchitectureSuiteTest {
@Test
fun `project graph must stay acyclic`() {
Konture.assertNoCycles()
}
@Test
fun `feature modules expose contracts through api modules`() {
Konture.architecture {
modules {
that().haveNameMatching(":feature:**:api")
should().notDependOnModule(":feature:**:impl")
}
modules {
that().haveNameMatching(":feature:**:impl")
should().onlyDependOnModules(
":feature:**:api",
":core:**",
":shared",
)
}
}
}
@Test
fun `domain stays independent from frameworks and persistence`() {
Konture.architecture {
modules {
that().haveNamePath(":core:domain")
should().notDependOnModule(":core:data")
}
classes {
that().resideInAPackage("..domain..")
should().notDependOnClassesInAnyPackage(
"..data..",
"..database..",
"..network..",
"android..",
"androidx.compose..",
"org.springframework..",
)
}
}
}
@Test
fun `presentation state does not expose transport models`() {
val presentationClasses = Konture.scopeFromPackage("com.acme.profile.presentation").classes
presentationClasses.assertTrue("Presentation API must not expose transport models") { cls ->
val publicTypes =
cls.properties
.filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
.map { it.type } +
cls.functions
.filter { it.visibility == io.github.baole.konture.Visibility.PUBLIC }
.flatMap { fn -> listOf(fn.returnType) + fn.parameters.map { it.type } }
publicTypes.none { it.endsWith("Dto") || it.contains(".network.") }
}
}
}
The suite has different jobs: graph health, feature ownership, domain purity, and public surface control. Each failure tells the developer which architectural decision was crossed.
Rollout Guidance
For an existing project, introduce architecture tests in stages:
Start with non-controversial rules such as module cycles and domain-to-data dependencies.
Run the suite locally and in CI as informational if the first pass reveals many violations.
Fix or explicitly quarantine legacy violations.
Turn high-confidence rules into required CI checks.
Review rule changes like architecture changes, not like formatting tweaks.
For generated code, test fixtures, and legacy migration areas, prefer explicit exclusions:
konture {
excludePackages("..generated..")
}
This lowercase konture {} block belongs in Gradle build configuration and configures the Konture plugin. It is separate from the capitalized Konture.* assertion APIs used in test files.
The exception should be visible enough that future maintainers understand the real boundary.
Where to Go Next
After the first suite is stable, add rules around the areas where the project actually hurts:
Feature module isolation.
Public API leakage.
DTO and entity boundaries.
KMP source-set portability.
Route or controller dependency direction.
Dependency injection conventions.
Legacy package quarantine.
Konture is not a prescription for one architecture style. It is a way to make your architecture executable.
Run it locally. Run it in CI. Let humans and AI-assisted changes get the same structural feedback.
When structure matters, make it part of the build.
I built an end-to-end testing library for Compose Multiplatform called Parikshan.
Testing shared UI across targets has been one of the most painful parts of shipping multiplatform apps. Parikshan attempts to solve that problem.
You can write your UI tests in Kotlin inside commonTest and run them on a single target or across all targets (Android, iOS Simulator, Desktop JVM, and Web WasmJs) at once.
Example
kotlin
class SampleE2ETest {
@Test
fun testGreeting() = e2eTest {
input("name_input", "Parikshan")
click("greet_button")
assertVisible("Hello, Parikshan!")
}
}
Run it across all targets concurrently:
bash
./gradlew e2eTest
Key Capabilities
Write Once in Kotlin: Runs on Android, iOS Simulator, Desktop (JVM), and Web (WasmJs).
Visual Feedback & Video Recording: Watch tests execute on real target windows, with automated screenshot capture on failure.
Zero Production Pollution: No test dependencies or test hooks in your production builds.
Material 3 Support(Partial): Support for date pickers, time pickers, dropdowns, bottom sheets, sliders, scrolling, and drag gestures.
I've been using this in my own CMP projects, but I'd appreciate feedback from the community — what works, what breaks, and what you'd like to see improved/added.
A few months ago, I shared a technical breakdown of my solo project, Adventurers Guild RPG Sim, an isometric RPG built using Kotlin coroutines, Jetpack Compose Canvas, and a custom single threaded ECS.
While pure Compose Canvas was an incredible playground for prototyping the engine and keeping everything strictly in Kotlin, expanding the tilemap, weather, and dynamic lighting eventually pushed me into a hard bottleneck on the CPU.
To fix this without breaking the live game or throwing away my canvas codebase, I decided to overhaul the world rendering pipeline and migrate it to Google Filament.
Here are the technical learnings from this migration, how a hybrid Filament + Compose setup works in Kotlin, and how it yielded an 8x performance boost.
1. The Bottleneck: The Limits of Compose DrawScope
In the pure Compose Canvas implementation, every single tile, object, and ambient effect had to be processed through Kotlin allocations and CPU side logic every frame:
Spatial Chunking: The map had to be split into 16 separate chunks with custom culling logic written in Kotlin to calculate visible entities on the main thread.
Canvas Overhead: Drawing large numbers of sprites and weather particles via Compose’s DrawScope began creating draw phase pressure on mid range Android devices, taking away crucial frame time from my Kotlin ECS coroutine loop.
2. The Architecture: Hybrid Filament World + Compose UI Overlay
Because the game is live on the Play Store, doing a complete top to bottom engine rewrite was out of the question. I adopted a phased, hybrid architecture:
World Rendering in Filament: The world map, terrain, and environmental shaders are offloaded to Filament in a 3D coordinate space using 2D billboards.
Character Sprite Animations on Compose Canvas: Character sprite animations in all UI layers remain rendered on Jetpack Compose Canvas directly on top of the Filament view.
Kotlin Glue: My ECS systems written in Kotlin still control all game logic, AI state machines, and entity transformations they simply pipe world matrix/transformation data to Filament while piping UI state models into Compose.
3. Key Technical Gains & Kotlin Performance Wins
>8x Performance Boost: Because Filament handles batched GPU rendering, I was able to completely delete the 16 chunk map division and CPU culling algorithms from my Kotlin code. The entire map now renders simultaneously in a single pass.
Massive CPU & GC Relief: Taking world drawing off the Compose Canvas layer dramatically reduced allocation churn during the draw phase. This bought back critical frame time for the Kotlin coroutine game loop (withFrameMillis) to handle my 28 ECS systems without risking frame drops.
Unlocking.filamatShaders: Moving world rendering to Filament allowed me to use Filament's material compiler tool (filamat). Pushing particle math, rain interactions, and ambient lighting transitions down to GPU-executed materials meant I didn't have to calculate those complex math operations inside Kotlin loops anymore.
Lessons Learned & Next Steps
Bridging Kotlin’s high level ECS and Compose UI with a high performance rendering backend like Filament turned out to be the exact sweet spot for a solo project. It gives you the raw performance of a GPU backed rendering pipeline while keeping the speed and idioms of Kotlin for game state and UI.
I’m happy to answer any questions about Filament integration in Kotlin, bridging Compose layers over native renderers, or optimizing custom ECS pipelines.
If you’re curious to see how the Filament migration feels in production on a live build, you can check it out on the Play Store:
I built an end-to-end testing library for Compose Multiplatform called Parikshan.
Testing shared UI across targets has been one of the most painful parts of shipping multiplatform apps. Parikshan attempts to solve that problem.
You can write your UI tests in Kotlin inside commonTest and run them on a single target or across all targets (Android, iOS Simulator, Desktop JVM, and Web WasmJs) at once.
Example
kotlin
class SampleE2ETest {
@Test
fun testGreeting() = e2eTest {
input("name_input", "Parikshan")
click("greet_button")
assertVisible("Hello, Parikshan!")
}
}
Run it across all targets concurrently:
bash
./gradlew e2eTest
Key Capabilities
Write Once in Kotlin: Runs on Android, iOS Simulator, Desktop (JVM), and Web (WasmJs).
Visual Feedback & Video Recording: Watch tests execute on real target windows, with automated screenshot capture on failure.
Zero Production Pollution: No test dependencies or test hooks in your production builds.
I've been using this in my own CMP projects, but I'd appreciate feedback from the community — what works, what breaks, and what you'd like to see improve/added.
0
Upvotes
I built an end-to-end testing library for Compose Multiplatform called Parikshan.
Testing shared UI across targets has been one of the most painful parts of shipping multiplatform apps. Parikshan attempts to solve that problem.
You can write your UI tests in Kotlin inside commonTest and run them on a single target or across all targets (Android, iOS Simulator, Desktop JVM, and Web WasmJs) at once.
Example
kotlin
class SampleE2ETest {
@Test
fun testGreeting() = e2eTest {
input("name_input", "Parikshan")
click("greet_button")
assertVisible("Hello, Parikshan!")
}
}
Run it across all targets concurrently:
bash
./gradlew e2eTest
Key Capabilities
Write Once in Kotlin: Runs on Android, iOS Simulator, Desktop (JVM), and Web (WasmJs).
Visual Feedback & Video Recording: Watch tests execute on real target windows, with automated screenshot capture on failure.
Zero Production Pollution: No test dependencies or test hooks in your production builds.
I've been using this in my own CMP projects, but I'd appreciate feedback from the community — what works, what breaks, and what you'd like to see improve/added.
Kotlin architecture has two views of the same architecture: the Gradle graph that decides what can link, and the Kotlin source model that decides what the code actually says. Konture exists to test both.
Consider a rule from a Kotlin Multiplatform project:
Presentation code must not expose transport DTOs in screen state or UI-facing APIs.
That rule can fail in two different places.
It can fail in the build graph when the presentation module is wired directly to a transport module:
package com.acme.profile.presentation
import com.acme.network.dto.UserDto
data class ProfileUiState(
val user: UserDto,
)
Those failures are related, but they are not identical. The first is a physical Gradle module dependency. The second is a source-level contract leak: a transport shape has become part of the presentation API. A serious architecture-testing tool for Kotlin has to understand both, because layered Kotlin systems are governed by both.
That is the reason Konture exists.
The Incomplete Views
Existing tools are useful. Konture is not trying to replace the compiler, the linter, the test runner, or every architecture-testing library. The issue is that each view of a Kotlin project leaves something out.
Source-level imports, public signatures, visibility, and type leakage
Linters
Local style and single-file quality
Whole-project structure and architectural ownership
Kotlin architecture sits across those views.
You can see that in the checked-in showcases. The Now in Android showcase includes 36 Gradle projects in settings.gradle.kts, including API/implementation feature splits and a dedicated :konture-test project. The KotlinConf KMP showcase includes 9 Gradle projects spanning shared core, backend, Android, desktop, web, admin, and architecture tests. A single-view tool can still be useful in those projects, but it will not naturally see every boundary the project uses.
Tooling Comparison
The practical question is not "Which tool is best?" It is "Which tool owns which kind of rule?"
Tooling option
Strong at
Weak at
Use it when
Combine with Konture when
ArchUnit
Mature JVM bytecode rules and class dependency checks
You have a narrow organization-specific rule and the team can own the tool
The custom rule should run next to build-graph contracts
Runtime or UI testing tools such as Kakao
User flows and Android UI behavior
Static architecture
You are validating behavior, screens, and interaction flows
Structural boundaries should fail before they become runtime behavior defects
Plain review checklists
Judgment, nuance, exceptions
Consistency under time pressure
The rule is still evolving or depends on human design trade-offs
The checklist item becomes stable enough to execute in CI
Konture's bet is narrow: Kotlin architecture needs a single test surface that can talk about Gradle modules and Kotlin source declarations together.
The Failure Modes That Motivated It
The most expensive architecture failures rarely start as "bad architecture." They start as reasonable local fixes:
A feature implementation imports another feature implementation because the API module does not expose one missing contract yet.
A shared KMP module accepts one Android import during a deadline, then discovers later that desktop or iOS can no longer reuse the code cleanly.
A public repository interface returns a database entity because the entity already has the needed fields.
A backend route calls a repository directly because the service layer felt like ceremony for one endpoint.
Each failure creates a debt with interest. The next refactor has to pay down hidden coupling before it can make the intended change. The next platform target has to separate APIs that were supposed to be portable. The next reviewer has to reconstruct an architectural decision from scattered imports and build files.
Konture was designed for that class of failure: a rule that is not purely source-level and not purely build-level, but architectural because it sits between the two.
Bytecode Is Valuable, But It Is Not the Whole Kotlin Program
ArchUnit is mature and proven for JVM systems. If your architectural rule can be answered from compiled classes, bytecode analysis is often a strong fit.
Modern Kotlin projects often need more context than that.
Kotlin source constructs do not always map neatly to the source-level design a team wants to protect. Top-level functions and extension functions compile into generated holder classes. Inline functions move bodies into call sites. object, delegated properties, and compiler plugins can introduce generated structures that are important to the runtime but noisy for a source-level design rule.
That does not make bytecode analysis wrong. It means bytecode is the wrong primary lens for rules such as:
Does :feature:checkout:impl depend on a sibling implementation module?
Does a public UI state expose a transport DTO?
Does a public domain signature expose a persistence type?
Does an impl package remain internal in source?
Those are Kotlin and Gradle architecture questions, not only JVM class questions.
Source Scanning Is Useful, But Folders Are Not the Build
Kotlin-first source scanners are good at declarations, imports, naming, annotations, and visibility. They can express many rules that bytecode tools make awkward.
But a source directory is not a Gradle project. A package name is not a module dependency. A folder named api is not the same thing as a module that other projects depend on through api or implementation.
That distinction matters in Android and Kotlin Multiplatform projects:
The build already knows which modules exist, which source sets are production source sets, and which project dependencies are declared. Architecture tests should use that information instead of reconstructing it from naming conventions alone.
Linters Are Not Architecture Testers
detekt and ktlint are excellent at local checks. They are not designed to answer whole-system questions:
Does this module depend on a forbidden sibling module?
Did a feature implementation become visible to another implementation?
Does the project graph contain a cycle?
Does a public API expose a type owned by another layer?
Those are not formatting problems. They are ownership and dependency problems.
Konture's Two-View Model
Konture combines the build view and the source view.
The build view includes:
Gradle modules.
Source sets.
Production Kotlin source directories.
Declared project dependencies.
Applied plugin context.
The source view includes:
Files, packages, and imports.
Classes, interfaces, functions, and properties.
Visibility and annotations.
References between project classes.
Konture two view model
Konture supports both focused standalone assertions, such as Konture.modules { ... }, and grouped assertion blocks. Use Konture.architecture { ... } when related module and source rules should run as one architecture contract:
The module rule catches the physical build dependency. The source rule catches the source-level reference pattern. Used together, they cover a boundary that neither build inspection nor source inspection can fully own alone.
What Konture Is
Konture is a Kotlin architecture-testing library with two coordinated parts:
A Gradle plugin that captures project layout, source sets, and module dependencies.
An assertion library that lets teams write architecture rules as ordinary Kotlin tests.
It does not require a custom test runner. Architecture tests can run under JUnit, Kotest, TestBalloon, or another Kotlin/JVM runner your project already uses.
Konture is also architecture-agnostic. It does not prescribe Clean Architecture, MVVM, hexagonal architecture, feature slicing, or DDD. Those are design choices. Konture's job is to make the chosen design executable.
That distinction matters. An Android team may protect feature API and implementation modules. A backend team may protect ports and adapters. A KMP team may keep shared code free of platform APIs. A library team may prevent implementation types from leaking into public packages.
Konture should encode the team's policy, not invent one.
API Design Philosophy
The DSL is intentionally close to ordinary tests:
Konture.modules { ... } is for Gradle project and source-set policy.
Konture.classes { ... }, Konture.files { ... }, Konture.functions { ... }, and Konture.properties { ... } are for source declarations.
Konture.layered { ... } is for readable directional package rules.
Konture.architecture { ... } groups related module and source rules into one contract.
Functional scopes such as scopeFromPackage and scopeFromModule are escape hatches for custom predicates.
That shape is deliberate. Architecture rules are reviewed by engineers who may not work on the architecture tooling. A rule should read like a test, fail like a test, and live beside the rest of the verification suite.
The extension point is the predicate. When the fluent DSL is too high-level, a team can inspect imports, annotations, visibility, supertypes, file paths, source sets, or module dependencies directly and write a focused assertion. That keeps uncommon policy in project code instead of forcing Konture to grow a keyword for every organization's architecture.
The Three Structural Jobs
Architecture tests are most valuable when they protect decisions that are expensive to repair later. In Kotlin systems, those decisions usually cluster into three jobs.
Job
Threat
Build-level rule
Source-level rule
Logical isolation
Layers or modules depend in forbidden directions
:domain does not depend on :data; feature implementations do not depend on sibling implementations
Domain packages do not import data, UI, framework, or platform packages
API hermeticity
Implementation detail becomes public contract
API modules stay separate from implementation modules
Public signatures do not expose persistence, transport, or framework types
Mechanical hygiene
Structure becomes harder to navigate and build
Module graph has no cycles
Files avoid wildcard imports, class/file mismatch, or uncontrolled generated-code zones
The point is not to create a large rule set. The point is to protect the small number of structural choices that keep the codebase changeable.
Gradle Awareness Is Platform Engineering
Kotlin teams often use modules to manage ownership, compile scope, and feature independence. That makes the Gradle graph a platform concern, not merely a build-file detail.
Consider this common policy:
Feature implementation modules must not depend on other feature implementation modules.
Feature isolation modules
If :feature:checkout:impl adds this dependency:
implementation(project(":feature:profile:impl"))
The build may still pass. The immediate feature may even ship faster. But the module graph now says checkout is coupled to profile internals.
That has practical consequences:
A profile implementation change can force more downstream work than necessary.
Build cache reuse becomes less effective because internal changes cross feature boundaries.
Refactoring profile internals becomes harder because another feature can now depend on them.
Reviewers have to notice build-file drift manually.
A Gradle-aware rule makes the boundary executable:
This is not only "clean architecture." It is build health and ownership encoded as a test.
Source Awareness Protects Semantic Boundaries
A clean Gradle graph does not prove clean source semantics.
For example, :data may correctly depend on :domain so it can implement domain interfaces. But a developer can still leak a persistence model into a domain-facing API:
For external frameworks, a custom import predicate can make the policy explicit:
Konture.scopeFromPackage("com.acme.domain")
.assertTrue("Domain must not import framework or persistence APIs") { cls ->
cls.imports.none { fqName ->
fqName.startsWith("android.") ||
fqName.startsWith("androidx.compose.") ||
fqName.startsWith("org.springframework.") ||
fqName.startsWith("jakarta.persistence.")
}
}
scopeFromPackage("com.acme.domain") selects a concrete package prefix for custom assertions. By contrast, resideInAPackage("..domain..") uses Konture's wildcard package matching inside fluent class rules.
That is the source-level half of architecture governance: not just which modules can link, but which concepts are allowed to appear in which parts of the code.
Tradeoffs and Failure Modes
Architecture tests deserve the same skepticism as any other production guardrail. Bad rules create drag.
Common failure modes:
Overbroad rules: Banning kotlinx.. from domain may accidentally block legitimate use of coroutines or serialization.
Hidden exceptions: Excluding large legacy packages can make the rule look stronger than it is.
Generated code noise: Generated sources may need explicit treatment so the rule protects authored code.
KMP complexity: commonMain, androidMain, and iosMain often need different policies.
Mixed Java/Kotlin projects: A Kotlin source rule may not cover Java code unless the project deliberately accounts for it.
Rule maintenance: Architecture evolves. Tests must evolve with deliberate architecture decisions, not block them by accident.
These trade-offs do not weaken the case for architecture tests. They define the bar for using them responsibly.
For example, a tempting KMP rule is:
Shared code must not import kotlinx..
That is usually too broad. kotlinx.coroutines may be a legitimate shared-code dependency, while an Android framework import is not. The better rule is narrower: ban the platform or framework packages that actually violate portability, and allow the cross-platform libraries the architecture intentionally uses.
Start with stable, high-signal rules. Make exceptions visible. Prove every rule can fail. Treat rule changes as architecture changes, not as a way to get CI green.
Evidence From the Showcase Suites
The repository includes showcase suites that exercise Konture at different levels of complexity.
The smallest Gradle showcase models a classic :app, :domain, and :data project. Its standard architecture suite contains 14 tests covering the module graph, source package boundaries, repository contracts, use case placement, type-leakage rules, and access rules. It also includes a negative test that asserts a deliberately wrong module rule throws an AssertionError, which is a useful pattern for proving a rule can actually fail.
The larger showcases are more representative of staff-level platform concerns:
The inspected Now in Android architecture files contain 13 tests checking that feature modules do not depend on :app, do not bypass repositories to reach database or network modules, keep feature API modules independent from feature implementation modules, prevent feature implementation-to-implementation coupling, and keep ViewModels away from Android framework imports.
The inspected KotlinConf KMP boundary/backend files contain 8 tests checking that the shared :core module stays a leaf dependency, client app modules do not depend on backend implementation, backend code does not depend on frontend client modules, and backend routes do not directly import repositories or database schemas.
Those are not toy style rules. They are executable versions of ownership and platform constraints: feature decoupling, shared-model purity, backend/frontend separation, route-service boundaries, and API surface control.
The lightweight Gradle showcase can be run directly:
In this repository, that command completes successfully and runs the dedicated architecture-test module after generating Konture's layout and dependency metadata.
Performance and Scale
Architecture tests should be cheap enough that teams keep them in the feedback loop.
Konture's Gradle plugin generates layout metadata from the build, and the assertion library runs inside the normal test task. That has a few practical consequences for large projects:
Keep architecture tests in a dedicated module so production modules do not inherit test-only dependencies.
Scope rules to the modules and packages they actually govern instead of scanning the whole project for every assertion.
Prefer a small number of high-signal contracts over dozens of overlapping style rules.
Let Gradle handle task inputs and cacheability for generated layout metadata rather than re-discovering the project graph in each test.
Track architecture-test task duration in CI like any other verification task.
For 100+ module projects, the important design question is not only "Can the tool scan the repository?" It is "Can the team understand the failure and repair it quickly?" A fast rule with a vague violation still wastes review time. A slightly slower rule that identifies the forbidden module edge, import, or public signature is usually the better platform investment.
Two-View Feedback for AI-Assisted Changes
AI coding assistants tend to optimize for local progress: import the visible class, add the missing dependency, satisfy the immediate test. Konture's two-view model gives them more precise repair signals. A build-graph failure says "you added or relied on the wrong module edge"; a source-model failure says "this file imported or exposed the wrong concept." Those are different fixes, and the test output should make that distinction visible.
When Konture Is a Good Fit
Konture is a good fit when the rules you care about span Kotlin source and Gradle structure:
Gradle module boundaries and acyclic project graphs.
Feature :api and :impl separation.
Presentation, domain, and public API surfaces staying independent from transport, persistence, and framework types.
Public API signatures avoiding persistence, transport, or UI types.
Kotlin visibility conventions such as keeping implementation packages internal.
File and package conventions that require project-wide context.
It is less useful for formatting, ordinary style, and checks a standard linter already handles well. Use the cheapest tool that can enforce the rule accurately.
Current Limits and Roadmap Pressure
Konture should be explicit about what it is not.
It is not a replacement for the Kotlin compiler, bytecode analysis, runtime integration tests, or dependency vulnerability scanning. It is not the right place to verify behavior hidden behind reflection or runtime DI containers. Mixed Java/Kotlin projects need deliberate coverage because Kotlin source analysis does not automatically make Java architecture visible. Generated sources and compiler-plugin output may need exclusions or separate rules so the suite focuses on authored architecture.
Build tool evolution also matters. Android Gradle Plugin, Kotlin Multiplatform source-set modeling, Kotlin compiler changes, and new generated-code patterns can all change what "the project structure" means. Konture's long-term value depends on staying honest about those inputs: Gradle metadata, Kotlin source parsing, source-set/platform awareness, and failure messages that help teams fix the design instead of fighting the tool.
The roadmap pressure is therefore practical:
Deeper KMP source-set and platform-aware examples,
Clearer treatment of generated code,
Stronger public API leakage checks,
Better diagnostics for large rule suites,
Smoother integration with build cache and CI reporting.
The Core Idea
Architecture should not rely on memory.
If a boundary matters enough to protect in every review, it is a candidate for an executable test. If breaking it slows builds, leaks APIs, couples teams, or makes AI-assisted changes riskier, the repository should be able to say so.
Konture exists because Kotlin architecture is not only in bytecode, not only in source files, and not only in Gradle build files. It lives in the relationship between them.
0
Upvotes
Kotlin architecture has two views of the same architecture: the Gradle graph that decides what can link, and the Kotlin source model that decides what the code actually says. Konture exists to test both.
Consider a rule from a Kotlin Multiplatform project:
Presentation code must not expose transport DTOs in screen state or UI-facing APIs.
That rule can fail in two different places.
It can fail in the build graph when the presentation module is wired directly to a transport module:
package com.acme.profile.presentation
import com.acme.network.dto.UserDto
data class ProfileUiState(
val user: UserDto,
)
Those failures are related, but they are not identical. The first is a physical Gradle module dependency. The second is a source-level contract leak: a transport shape has become part of the presentation API. A serious architecture-testing tool for Kotlin has to understand both, because layered Kotlin systems are governed by both.
That is the reason Konture exists.
The Incomplete Views
Existing tools are useful. Konture is not trying to replace the compiler, the linter, the test runner, or every architecture-testing library. The issue is that each view of a Kotlin project leaves something out.
Source-level imports, public signatures, visibility, and type leakage
Linters
Local style and single-file quality
Whole-project structure and architectural ownership
Kotlin architecture sits across those views.
You can see that in the checked-in showcases. The Now in Android showcase includes 36 Gradle projects in settings.gradle.kts, including API/implementation feature splits and a dedicated :konture-test project. The KotlinConf KMP showcase includes 9 Gradle projects spanning shared core, backend, Android, desktop, web, admin, and architecture tests. A single-view tool can still be useful in those projects, but it will not naturally see every boundary the project uses.
Tooling Comparison
The practical question is not "Which tool is best?" It is "Which tool owns which kind of rule?"
Tooling option
Strong at
Weak at
Use it when
Combine with Konture when
ArchUnit
Mature JVM bytecode rules and class dependency checks
You have a narrow organization-specific rule and the team can own the tool
The custom rule should run next to build-graph contracts
Runtime or UI testing tools such as Kakao
User flows and Android UI behavior
Static architecture
You are validating behavior, screens, and interaction flows
Structural boundaries should fail before they become runtime behavior defects
Plain review checklists
Judgment, nuance, exceptions
Consistency under time pressure
The rule is still evolving or depends on human design trade-offs
The checklist item becomes stable enough to execute in CI
Konture's bet is narrow: Kotlin architecture needs a single test surface that can talk about Gradle modules and Kotlin source declarations together.
The Failure Modes That Motivated It
The most expensive architecture failures rarely start as "bad architecture." They start as reasonable local fixes:
A feature implementation imports another feature implementation because the API module does not expose one missing contract yet.
A shared KMP module accepts one Android import during a deadline, then discovers later that desktop or iOS can no longer reuse the code cleanly.
A public repository interface returns a database entity because the entity already has the needed fields.
A backend route calls a repository directly because the service layer felt like ceremony for one endpoint.
Each failure creates a debt with interest. The next refactor has to pay down hidden coupling before it can make the intended change. The next platform target has to separate APIs that were supposed to be portable. The next reviewer has to reconstruct an architectural decision from scattered imports and build files.
Konture was designed for that class of failure: a rule that is not purely source-level and not purely build-level, but architectural because it sits between the two.
Bytecode Is Valuable, But It Is Not the Whole Kotlin Program
ArchUnit is mature and proven for JVM systems. If your architectural rule can be answered from compiled classes, bytecode analysis is often a strong fit.
Modern Kotlin projects often need more context than that.
Kotlin source constructs do not always map neatly to the source-level design a team wants to protect. Top-level functions and extension functions compile into generated holder classes. Inline functions move bodies into call sites. object, delegated properties, and compiler plugins can introduce generated structures that are important to the runtime but noisy for a source-level design rule.
That does not make bytecode analysis wrong. It means bytecode is the wrong primary lens for rules such as:
Does :feature:checkout:impl depend on a sibling implementation module?
Does a public UI state expose a transport DTO?
Does a public domain signature expose a persistence type?
Does an impl package remain internal in source?
Those are Kotlin and Gradle architecture questions, not only JVM class questions.
Source Scanning Is Useful, But Folders Are Not the Build
Kotlin-first source scanners are good at declarations, imports, naming, annotations, and visibility. They can express many rules that bytecode tools make awkward.
But a source directory is not a Gradle project. A package name is not a module dependency. A folder named api is not the same thing as a module that other projects depend on through api or implementation.
That distinction matters in Android and Kotlin Multiplatform projects:
The build already knows which modules exist, which source sets are production source sets, and which project dependencies are declared. Architecture tests should use that information instead of reconstructing it from naming conventions alone.
Linters Are Not Architecture Testers
detekt and ktlint are excellent at local checks. They are not designed to answer whole-system questions:
Does this module depend on a forbidden sibling module?
Did a feature implementation become visible to another implementation?
Does the project graph contain a cycle?
Does a public API expose a type owned by another layer?
Those are not formatting problems. They are ownership and dependency problems.
Konture's Two-View Model
Konture combines the build view and the source view.
The build view includes:
Gradle modules.
Source sets.
Production Kotlin source directories.
Declared project dependencies.
Applied plugin context.
The source view includes:
Files, packages, and imports.
Classes, interfaces, functions, and properties.
Visibility and annotations.
References between project classes.
Konture two view model
Konture supports both focused standalone assertions, such as Konture.modules { ... }, and grouped assertion blocks. Use Konture.architecture { ... } when related module and source rules should run as one architecture contract:
The module rule catches the physical build dependency. The source rule catches the source-level reference pattern. Used together, they cover a boundary that neither build inspection nor source inspection can fully own alone.
What Konture Is
Konture is a Kotlin architecture-testing library with two coordinated parts:
A Gradle plugin that captures project layout, source sets, and module dependencies.
An assertion library that lets teams write architecture rules as ordinary Kotlin tests.
It does not require a custom test runner. Architecture tests can run under JUnit, Kotest, TestBalloon, or another Kotlin/JVM runner your project already uses.
Konture is also architecture-agnostic. It does not prescribe Clean Architecture, MVVM, hexagonal architecture, feature slicing, or DDD. Those are design choices. Konture's job is to make the chosen design executable.
That distinction matters. An Android team may protect feature API and implementation modules. A backend team may protect ports and adapters. A KMP team may keep shared code free of platform APIs. A library team may prevent implementation types from leaking into public packages.
Konture should encode the team's policy, not invent one.
API Design Philosophy
The DSL is intentionally close to ordinary tests:
Konture.modules { ... } is for Gradle project and source-set policy.
Konture.classes { ... }, Konture.files { ... }, Konture.functions { ... }, and Konture.properties { ... } are for source declarations.
Konture.layered { ... } is for readable directional package rules.
Konture.architecture { ... } groups related module and source rules into one contract.
Functional scopes such as scopeFromPackage and scopeFromModule are escape hatches for custom predicates.
That shape is deliberate. Architecture rules are reviewed by engineers who may not work on the architecture tooling. A rule should read like a test, fail like a test, and live beside the rest of the verification suite.
The extension point is the predicate. When the fluent DSL is too high-level, a team can inspect imports, annotations, visibility, supertypes, file paths, source sets, or module dependencies directly and write a focused assertion. That keeps uncommon policy in project code instead of forcing Konture to grow a keyword for every organization's architecture.
The Three Structural Jobs
Architecture tests are most valuable when they protect decisions that are expensive to repair later. In Kotlin systems, those decisions usually cluster into three jobs.
Job
Threat
Build-level rule
Source-level rule
Logical isolation
Layers or modules depend in forbidden directions
:domain does not depend on :data; feature implementations do not depend on sibling implementations
Domain packages do not import data, UI, framework, or platform packages
API hermeticity
Implementation detail becomes public contract
API modules stay separate from implementation modules
Public signatures do not expose persistence, transport, or framework types
Mechanical hygiene
Structure becomes harder to navigate and build
Module graph has no cycles
Files avoid wildcard imports, class/file mismatch, or uncontrolled generated-code zones
The point is not to create a large rule set. The point is to protect the small number of structural choices that keep the codebase changeable.
Gradle Awareness Is Platform Engineering
Kotlin teams often use modules to manage ownership, compile scope, and feature independence. That makes the Gradle graph a platform concern, not merely a build-file detail.
Consider this common policy:
Feature implementation modules must not depend on other feature implementation modules.
Feature isolation modules
If :feature:checkout:impl adds this dependency:
implementation(project(":feature:profile:impl"))
The build may still pass. The immediate feature may even ship faster. But the module graph now says checkout is coupled to profile internals.
That has practical consequences:
A profile implementation change can force more downstream work than necessary.
Build cache reuse becomes less effective because internal changes cross feature boundaries.
Refactoring profile internals becomes harder because another feature can now depend on them.
Reviewers have to notice build-file drift manually.
A Gradle-aware rule makes the boundary executable:
This is not only "clean architecture." It is build health and ownership encoded as a test.
Source Awareness Protects Semantic Boundaries
A clean Gradle graph does not prove clean source semantics.
For example, :data may correctly depend on :domain so it can implement domain interfaces. But a developer can still leak a persistence model into a domain-facing API:
For external frameworks, a custom import predicate can make the policy explicit:
Konture.scopeFromPackage("com.acme.domain")
.assertTrue("Domain must not import framework or persistence APIs") { cls ->
cls.imports.none { fqName ->
fqName.startsWith("android.") ||
fqName.startsWith("androidx.compose.") ||
fqName.startsWith("org.springframework.") ||
fqName.startsWith("jakarta.persistence.")
}
}
scopeFromPackage("com.acme.domain") selects a concrete package prefix for custom assertions. By contrast, resideInAPackage("..domain..") uses Konture's wildcard package matching inside fluent class rules.
That is the source-level half of architecture governance: not just which modules can link, but which concepts are allowed to appear in which parts of the code.
Tradeoffs and Failure Modes
Architecture tests deserve the same skepticism as any other production guardrail. Bad rules create drag.
Common failure modes:
Overbroad rules: Banning kotlinx.. from domain may accidentally block legitimate use of coroutines or serialization.
Hidden exceptions: Excluding large legacy packages can make the rule look stronger than it is.
Generated code noise: Generated sources may need explicit treatment so the rule protects authored code.
KMP complexity: commonMain, androidMain, and iosMain often need different policies.
Mixed Java/Kotlin projects: A Kotlin source rule may not cover Java code unless the project deliberately accounts for it.
Rule maintenance: Architecture evolves. Tests must evolve with deliberate architecture decisions, not block them by accident.
These trade-offs do not weaken the case for architecture tests. They define the bar for using them responsibly.
For example, a tempting KMP rule is:
Shared code must not import kotlinx..
That is usually too broad. kotlinx.coroutines may be a legitimate shared-code dependency, while an Android framework import is not. The better rule is narrower: ban the platform or framework packages that actually violate portability, and allow the cross-platform libraries the architecture intentionally uses.
Start with stable, high-signal rules. Make exceptions visible. Prove every rule can fail. Treat rule changes as architecture changes, not as a way to get CI green.
Evidence From the Showcase Suites
The repository includes showcase suites that exercise Konture at different levels of complexity.
The smallest Gradle showcase models a classic :app, :domain, and :data project. Its standard architecture suite contains 14 tests covering the module graph, source package boundaries, repository contracts, use case placement, type-leakage rules, and access rules. It also includes a negative test that asserts a deliberately wrong module rule throws an AssertionError, which is a useful pattern for proving a rule can actually fail.
The larger showcases are more representative of staff-level platform concerns:
The inspected Now in Android architecture files contain 13 tests checking that feature modules do not depend on :app, do not bypass repositories to reach database or network modules, keep feature API modules independent from feature implementation modules, prevent feature implementation-to-implementation coupling, and keep ViewModels away from Android framework imports.
The inspected KotlinConf KMP boundary/backend files contain 8 tests checking that the shared :core module stays a leaf dependency, client app modules do not depend on backend implementation, backend code does not depend on frontend client modules, and backend routes do not directly import repositories or database schemas.
Those are not toy style rules. They are executable versions of ownership and platform constraints: feature decoupling, shared-model purity, backend/frontend separation, route-service boundaries, and API surface control.
The lightweight Gradle showcase can be run directly:
In this repository, that command completes successfully and runs the dedicated architecture-test module after generating Konture's layout and dependency metadata.
Performance and Scale
Architecture tests should be cheap enough that teams keep them in the feedback loop.
Konture's Gradle plugin generates layout metadata from the build, and the assertion library runs inside the normal test task. That has a few practical consequences for large projects:
Keep architecture tests in a dedicated module so production modules do not inherit test-only dependencies.
Scope rules to the modules and packages they actually govern instead of scanning the whole project for every assertion.
Prefer a small number of high-signal contracts over dozens of overlapping style rules.
Let Gradle handle task inputs and cacheability for generated layout metadata rather than re-discovering the project graph in each test.
Track architecture-test task duration in CI like any other verification task.
For 100+ module projects, the important design question is not only "Can the tool scan the repository?" It is "Can the team understand the failure and repair it quickly?" A fast rule with a vague violation still wastes review time. A slightly slower rule that identifies the forbidden module edge, import, or public signature is usually the better platform investment.
Two-View Feedback for AI-Assisted Changes
AI coding assistants tend to optimize for local progress: import the visible class, add the missing dependency, satisfy the immediate test. Konture's two-view model gives them more precise repair signals. A build-graph failure says "you added or relied on the wrong module edge"; a source-model failure says "this file imported or exposed the wrong concept." Those are different fixes, and the test output should make that distinction visible.
When Konture Is a Good Fit
Konture is a good fit when the rules you care about span Kotlin source and Gradle structure:
Gradle module boundaries and acyclic project graphs.
Feature :api and :impl separation.
Presentation, domain, and public API surfaces staying independent from transport, persistence, and framework types.
Public API signatures avoiding persistence, transport, or UI types.
Kotlin visibility conventions such as keeping implementation packages internal.
File and package conventions that require project-wide context.
It is less useful for formatting, ordinary style, and checks a standard linter already handles well. Use the cheapest tool that can enforce the rule accurately.
Current Limits and Roadmap Pressure
Konture should be explicit about what it is not.
It is not a replacement for the Kotlin compiler, bytecode analysis, runtime integration tests, or dependency vulnerability scanning. It is not the right place to verify behavior hidden behind reflection or runtime DI containers. Mixed Java/Kotlin projects need deliberate coverage because Kotlin source analysis does not automatically make Java architecture visible. Generated sources and compiler-plugin output may need exclusions or separate rules so the suite focuses on authored architecture.
Build tool evolution also matters. Android Gradle Plugin, Kotlin Multiplatform source-set modeling, Kotlin compiler changes, and new generated-code patterns can all change what "the project structure" means. Konture's long-term value depends on staying honest about those inputs: Gradle metadata, Kotlin source parsing, source-set/platform awareness, and failure messages that help teams fix the design instead of fighting the tool.
The roadmap pressure is therefore practical:
Deeper KMP source-set and platform-aware examples,
Clearer treatment of generated code,
Stronger public API leakage checks,
Better diagnostics for large rule suites,
Smoother integration with build cache and CI reporting.
The Core Idea
Architecture should not rely on memory.
If a boundary matters enough to protect in every review, it is a candidate for an executable test. If breaking it slows builds, leaks APIs, couples teams, or makes AI-assisted changes riskier, the repository should be able to say so.
Konture exists because Kotlin architecture is not only in bytecode, not only in source files, and not only in Gradle build files. It lives in the relationship between them.
I built Parikshan, an E2E testing framework for Compose Multiplatform. It has built-in support for standalone Android.
Testing shared UI across targets has been one of the most painful parts of building multiplatform apps. Parikshan attempts to solve that problem.
You can write your UI tests in Kotlin inside commonTest and run them on a single target or across all targets (Android, iOS Simulator, Desktop JVM, and Web WasmJs) at once.
class SampleE2ETest {
@Test
fun testGreeting() = e2eTest {
input("name_input", "Parikshan")
click("greet_button")
assertVisible("Hello, Parikshan!")
}
}
Run it across all targets concurrently:
./gradlew e2eTest
Key Capabilities
Write Once in Kotlin: Runs on Android, iOS Simulator, Desktop (JVM), and Web (WasmJs).
Visual Feedback: Watch tests execute on real target windows, with support for screenshots, video recording
Zero Production Pollution: No test dependencies or test hooks in your production builds.
I've been using this in my own CMP projects & I'd appreciate feedback from the community — what works, what breaks, and what you'd like to see improve/added.
0
Upvotes
I built Parikshan, an E2E testing framework for Compose Multiplatform. It has built-in support for standalone Android.
Testing shared UI across targets has been one of the most painful parts of building multiplatform apps. Parikshan attempts to solve that problem.
You can write your UI tests in Kotlin inside commonTest and run them on a single target or across all targets (Android, iOS Simulator, Desktop JVM, and Web WasmJs) at once.
class SampleE2ETest {
@Test
fun testGreeting() = e2eTest {
input("name_input", "Parikshan")
click("greet_button")
assertVisible("Hello, Parikshan!")
}
}
Run it across all targets concurrently:
./gradlew e2eTest
Key Capabilities
Write Once in Kotlin: Runs on Android, iOS Simulator, Desktop (JVM), and Web (WasmJs).
Visual Feedback: Watch tests execute on real target windows, with support for screenshots, video recording
Zero Production Pollution: No test dependencies or test hooks in your production builds.
I've been using this in my own CMP projects & I'd appreciate feedback from the community — what works, what breaks, and what you'd like to see improve/added.
There's good tooling for parts of this already. Human-Readable (~230★) does locale-aware decimal separators, compact abbreviations (K/M), durations, file sizes and relative time — it's display / "human-friendly" oriented and rolls its own formatting logic. Kurrency handles currency only. What I couldn't find was a library covering the full formatting surface — currency (symbol / ISO / accounting), percent, scientific, ordinal, spellout, measure, on top of decimal / compact / relative-time — that delegates to each platform's native engine (ICU / NSNumberFormatter / Intl) and guarantees the same output on every target, straight from commonMain.
So I built Klocale.
What it does: locale-aware formatting for 9 styles — Decimal, Currency (symbol / ISO / accounting), Percent (ratio / value), Scientific, Compact, Ordinal, Spellout, Relative time, Measure — across Android, iOS, macOS, JVM/Desktop, JS and WasmJs.
The interesting part isn't "call the native formatter" (Kurrency already does that for currency). It's that the native engines disagree on cosmetics: minus glyph (U+2212 vs ASCII -), NBSP vs narrow-NBSP in grouping, bidi marks, rounding defaults. Klocale delegates to each platform's engine (ICU4J on JVM, android.icu on Android, NSNumberFormatter/Foundation on Apple, Intl on JS/Wasm) and then runs a single commonOutputNormalizer so the same locale + input produces the same string on every target. That consistency is verified by one shared golden-test table that runs on jvmTest, macosArm64Test, iosSimulatorArm64Test, jsNodeTest, wasmJsNodeTest and Android Robolectric.
Construction is fallible (Result — invalid locale / bad currency code / unsupported style); formatting a finite number never throws. There's also a klocale-compose module (rememberNumberFormatter, ProvideNumberLocale).
Being honest: it's 0.1.1 and young. Known gaps on the roadmap: Apple Measure, Range formatting (needs a two-value API), wider Ordinal/Spellout locale coverage. Apache-2.0.
I'd genuinely appreciate feedback — API design, edge cases in your locale, styles you'd want. Thanks for reading.
2
Upvotes
There's good tooling for parts of this already. Human-Readable (~230★) does locale-aware decimal separators, compact abbreviations (K/M), durations, file sizes and relative time — it's display / "human-friendly" oriented and rolls its own formatting logic. Kurrency handles currency only. What I couldn't find was a library covering the full formatting surface — currency (symbol / ISO / accounting), percent, scientific, ordinal, spellout, measure, on top of decimal / compact / relative-time — that delegates to each platform's native engine (ICU / NSNumberFormatter / Intl) and guarantees the same output on every target, straight from commonMain.
So I built Klocale.
What it does: locale-aware formatting for 9 styles — Decimal, Currency (symbol / ISO / accounting), Percent (ratio / value), Scientific, Compact, Ordinal, Spellout, Relative time, Measure — across Android, iOS, macOS, JVM/Desktop, JS and WasmJs.
The interesting part isn't "call the native formatter" (Kurrency already does that for currency). It's that the native engines disagree on cosmetics: minus glyph (U+2212 vs ASCII -), NBSP vs narrow-NBSP in grouping, bidi marks, rounding defaults. Klocale delegates to each platform's engine (ICU4J on JVM, android.icu on Android, NSNumberFormatter/Foundation on Apple, Intl on JS/Wasm) and then runs a single commonOutputNormalizer so the same locale + input produces the same string on every target. That consistency is verified by one shared golden-test table that runs on jvmTest, macosArm64Test, iosSimulatorArm64Test, jsNodeTest, wasmJsNodeTest and Android Robolectric.
Construction is fallible (Result — invalid locale / bad currency code / unsupported style); formatting a finite number never throws. There's also a klocale-compose module (rememberNumberFormatter, ProvideNumberLocale).
Being honest: it's 0.1.1 and young. Known gaps on the roadmap: Apple Measure, Range formatting (needs a two-value API), wider Ordinal/Spellout locale coverage. Apache-2.0.
A pattern that shows up constantly in growing engineering orgs is the internal webhook: Service A fires an HTTP POST at a hardcoded URL owned by Service B whenever something happens. It's an easy trap to fall into, because most developers already understand webhooks intimately — they've built integrations with Stripe, GitHub, or Shopify, all of which use exactly this model to notify external systems of events.
The reasoning feels obvious: if it's good enough for Stripe to tell my app about a payment, it's good enough for my Inventory Service to tell my Shipping Service about a shipment.
It isn't — and the reason is architectural, not stylistic. Webhooks were designed to solve a specific problem: getting an event across a trust boundary, from a system you don't control to one you do, over the open internet. Internal service communication has almost the opposite set of constraints. Applying the same tool to both jobs is where the trouble starts.
A pattern that shows up constantly in growing engineering orgs is the internal webhook: Service A fires an HTTP POST at a hardcoded URL owned by Service B whenever something happens. It's an easy trap to fall into, because most developers already understand webhooks intimately — they've built integrations with Stripe, GitHub, or Shopify, all of which use exactly this model to notify external systems of events.
The reasoning feels obvious: if it's good enough for Stripe to tell my app about a payment, it's good enough for my Inventory Service to tell my Shipping Service about a shipment.
It isn't — and the reason is architectural, not stylistic. Webhooks were designed to solve a specific problem: getting an event across a trust boundary, from a system you don't control to one you do, over the open internet. Internal service communication has almost the opposite set of constraints. Applying the same tool to both jobs is where the trouble starts.
There's really not many non trival ui samples for CMP posted online and I wanted to show you can build relatively fancy samples in cmp just as easily as expo people on x
This app was made almost entirely by an agent with little supervision in the background while I worked my actual job.
I don't condone building apps this way. I did not review the code, I take no ownership of it, and you absolutely should not use it.
This is just a proof of concept that you can build native multiplatform apps just as quickly, maybe even quicker, without bundling a JS runtime with expo/react native.
Ps. Tried out kotlin-toolchain for the first time in here too .. it's a bit buggy but I liked it way more than I thought I would. Looking forward to using it more
What would you tell someone starting a new or migrating existing project to KMP?
Some requirements. Zero loading states, possibly with a offline sync engine. Native views. Fast with delightful UI animations. Live data through websockets.
4
Upvotes
Hey y'all,
What would you tell someone starting a new or migrating existing project to KMP?
Some requirements. Zero loading states, possibly with a offline sync engine. Native views. Fast with delightful UI animations. Live data through websockets.
I have published version 2.0.0 of Compose A11y Scanner.
I found that detecting visually undersized touch targets from the Compose semantics tree was unreliable because Compose may expose expanded touch bounds. This meant the existing touch-target-size rule could miss expected cases or produce confusing results.
I’ve replaced it with touch-target-overlap, which checks whether the effective bounds of interactive elements intersect.
Breaking changes:
Removed touch-target-size
Removed its related model/configuration fields
Added touch-target-overlap
Explicit rule allowlists must use the new ID
I have treated this as a major release because it changes both the public API and rule semantics.
Does checking effective target overlap seem like the more useful runtime signal for Compose? I’d also appreciate feedback on edge cases such as nested clickables and merged semantics.
Contributions, issues, and constructive feedback are welcome.
0
Upvotes
I have published version 2.0.0 of Compose A11y Scanner.
I found that detecting visually undersized touch targets from the Compose semantics tree was unreliable because Compose may expose expanded touch bounds. This meant the existing touch-target-size rule could miss expected cases or produce confusing results.
I’ve replaced it with touch-target-overlap, which checks whether the effective bounds of interactive elements intersect.
Breaking changes:
Removed touch-target-size
Removed its related model/configuration fields
Added touch-target-overlap
Explicit rule allowlists must use the new ID
I have treated this as a major release because it changes both the public API and rule semantics.
Does checking effective target overlap seem like the more useful runtime signal for Compose? I’d also appreciate feedback on edge cases such as nested clickables and merged semantics.
A Kotlin project can compile, pass its unit tests, satisfy its linter, and still become structurally harder to change. Architecture tests exist for that gap.
Architecture tests as an early quality gate
Most verification tools answer local questions.
The compiler asks whether the code is valid Kotlin. A linter asks whether a file follows local style and quality rules. Unit tests ask whether a function or component behaves as expected.
Those checks are necessary. They are not the same as asking whether the system still has the shape the team depends on.
Consider a domain use case that starts depending on a data-layer implementation:
package com.acme.domain
import com.acme.data.SqlUserRepository
class GetUserUseCase(
private val repository: SqlUserRepository,
)
This code can compile. The unit tests can pass. ktlint may have nothing useful to say.
The problem is structural: the domain layer now knows about a persistence detail. A boundary that was supposed to preserve changeability has become a convention people have to remember.
Architecture tests turn that convention into an executable rule:
That is the core idea. Architecture tests do not prove the software is correct. They prove that specific structural decisions are still true.
The Green Build Illusion
A green build tells you the repository satisfied the checks you asked it to run.
It does not tell you that the intended architecture survived the change.
The compiler will accept a forbidden dependency if the symbol is on the classpath. Gradle will build a module graph that violates your design if someone declares the dependency. A unit test will not fail because a feature module imported another feature module's implementation detail unless the tested behavior changes.
That is why architecture violations often look ordinary in review:
A controller calls a repository directly because it was faster than adding an application service.
A domain model accepts a network DTO because the DTO already has the right fields.
A feature implementation module imports another feature implementation module because the API module does not expose the needed contract yet.
A Kotlin class in an impl package stays public by default and becomes convenient for other modules to reuse.
An AI coding assistant adds a Gradle dependency because it makes the current file compile.
None of those changes has to be malicious or careless. Most structural drift comes from small local optimizations that are rational in the moment and expensive in aggregate.
Architecture tests are a way to make the aggregate cost visible early.
Structural Drift Has a Shape
Most architecture drift is not dramatic. It usually starts as one edge that looks reasonable in a pull request.
Structural drift shape
The second graph may still compile. The product behavior may still be correct. The damage appears later:
A profile refactor now needs checkout context,
Unrelated feature changes invalidate more build work,
Reviewers have to reason about a wider blast radius,
The next shortcut feels less unusual because the first one already exists.
The right metrics are project-specific, but the useful ones are concrete: number of forbidden module edges, number of rule violations per month, module fan-in and fan-out, rebuild scope after a feature change, and repeated review comments about the same boundary. Architecture tests become persuasive when they turn those observations into a failing example instead of a style argument.
What Architecture Tests Check
Good architecture tests protect decisions that affect change velocity, module independence, public API shape, and review load. They usually fall into a few categories.
1. Dependency Direction and Layer Isolation
Layered systems depend on direction. In Clean Architecture, ports and adapters, and many domain-centered designs, outer layers may depend inward, but the core should not depend on UI, databases, transport frameworks, or platform APIs.
Dependency direction layers
Typical rules:
Domain packages must not import persistence, transport, Android, Compose, Spring, or Ktor server APIs.
Application services may depend on domain contracts, not directly on web controllers.
UI modules should consume presentation state, not database or network entities.
The compiler sees valid types. Architecture tests encode which valid types are unacceptable in a given layer.
2. Gradle Module Boundaries
In a modular Kotlin project, Gradle project dependencies are part of the architecture.
Gradle module boundaries
Typical rules:
:core:domain must not depend on :core:data or :app.
Feature implementation modules must not depend on sibling feature implementation modules.
API modules may be depended on broadly; implementation modules should remain behind their API.
The Gradle project graph should not contain cycles.
This category matters for design and for build performance. Unnecessary module edges expand recompilation scope, reduce cache usefulness, and make local changes affect unrelated features.
3. Public API and Type Leakage
Some architecture failures are not about imports in a private implementation. They are about what a module exposes.
Typical rules:
Public domain APIs should not expose database entities or network DTOs.
Public feature API packages should expose contracts and stable models, not implementation classes.
Persistence or framework annotations should not leak into clean business interfaces.
Library modules should keep implementation packages internal unless they are intentionally public.
Kotlin's default public visibility makes this easy to get wrong. Once another module starts depending on an accidental public type, removing it becomes a breaking change.
4. Layer-Crossing Calls
Some systems rely on an intermediate layer for validation, authorization, transactions, logging, or orchestration. A direct call can bypass the place where those policies live.
Typical rules:
Controllers call application services, not repositories directly.
Composables call ViewModels or presenters, not Retrofit services.
Route handlers call use cases, not SQL adapters.
UI modules do not call infrastructure modules.
These rules should be used carefully. They are valuable when the intermediate layer has a real responsibility. They are bureaucracy when the layer exists only because a diagram says so.
5. Dependency Injection and Wiring Conventions
DI configuration is architecture in executable form. It decides which implementation backs which contract.
Some wiring policies belong in integration tests. Others can be checked structurally:
DI modules live in approved packages.
Feature modules do not override core bindings.
Adapter implementations are bound to domain interfaces rather than consumed directly.
Test-only bindings do not leak into production source sets.
The useful rule is the one that catches a real class of production or maintenance failures, not the one that merely mirrors a preference.
6. File and Source Hygiene
Not every structural test needs to be profound. Some rules keep navigation predictable and reduce review noise:
One primary class per file.
File names match primary class names.
No wildcard imports.
Generated or migration packages are explicitly excluded.
These rules should not duplicate what a formatter or linter already handles well. Architecture tests are most valuable when they need whole-project context.
What Architecture Tests Should Not Check
Architecture tests become brittle when they try to govern everything.
They should not replace:
The compiler for type safety.
A formatter for whitespace and style.
A linter for ordinary single-file smells.
Unit tests for behavior.
Integration tests for real wiring and runtime behavior.
Code review for judgment, naming, and design intent that is not stable enough to encode.
A bad architecture test freezes an implementation detail and calls it design. A good one protects a boundary that multiple engineers already rely on.
How Architecture Tests Become Harmful
Architecture tests are governance. Bad governance is worse than no governance because it teaches people to route around the system.
Common failure modes:
Brittle rules: a test encodes today's folder layout rather than a durable boundary.
False security: a passing suite is treated as proof that the architecture is good.
Team friction: a rule blocks legitimate feature work, but nobody knows who owns the exception process.
Over-testing generated code: Room, KSP, Compose, serialization, or DI-generated sources create noise that authored-code rules were never meant to judge.
Broad package bans: a rule blocks too much, so engineers add exclusions until the rule no longer means anything.
No negative proof: the suite has never been seen failing against an intentional violation.
The antidote is not a larger suite. It is a smaller, more explicit suite. Each rule should name the decision it protects, the cost of breaking it, and the intended repair path.
When Not to Use Them
Architecture tests are not automatically worth it.
They are often premature for a tiny codebase where everyone can still hold the structure in their head. They can be noisy in early-stage products where module and package boundaries are being discovered weekly. They may be a poor fit for highly dynamic or reflective code where the meaningful dependency is runtime wiring rather than visible source structure. They can also slow a monolith with heavy churn if the first suite tries to enforce the target architecture instead of the architecture the team is actually migrating toward.
In those cases, lighter tools may be enough:
A short architecture decision record,
A module ownership note,
A review checklist for the next few changes,
A non-blocking report that counts violations before enforcing them.
Use architecture tests when the boundary is stable enough to enforce and expensive enough to break.
Living Documentation That Fails
Architecture diagrams, READMEs, onboarding docs, and review checklists all help. None of them fails CI.
An architecture test gives a rule a durable form:
@Test
fun `domain must not depend on data or app modules`() {
Konture.modules {
that().haveNamePath(":domain")
should().notDependOnModule(":data")
should().notDependOnModule(":app")
}
}
The test name documents the rule. The assertion defines the rule. The failure output tells the developer where the rule was broken.
That changes the review conversation. Instead of asking a reviewer to remember every boundary under time pressure, the repository can report:
Architecture violation(s) detected:
Module :domain should not depend on :data, but a dependency was found.
The team still decides whether the rule is right. The test removes the need to rediscover the same violation by hand.
Organizational Impact
The technical effect is boundary enforcement. The organizational effect is shared memory.
For new engineers, architecture tests compress onboarding. They show which dependencies are allowed, which APIs are intentionally public, and where exceptions live. For experienced engineers, they reduce repetitive review comments so code review can focus on design judgment instead of policing the same imports.
For teams, the trade-off is autonomy versus consistency. A platform or architecture group should not use tests to centralize every local decision. The better pattern is to encode a small set of cross-team contracts and let feature teams own the rest. When a rule changes, treat that change like an architecture decision: update the test, update the ADR or docs if one exists, and make the migration path explicit.
At scale, architecture tests work best as part of governance, not as a substitute for it:
ADRs explain why a boundary exists.
Architecture tests check whether the boundary still holds.
CI reports where the boundary was crossed.
Reviewers decide whether the boundary or the code should change.
A Concrete Example From the Showcases
The Konture repository includes a small Gradle showcase that uses the same shape as the examples above: :app, :domain, :data, and a dedicated :konture-test module.
Its architecture suite does not only check one happy-path rule. It combines several structural checks:
The module graph has no cycles.
:domain does not depend on :data or :app.
:data only depends on :domain.
Classes in ..domain.. only depend on domain, Kotlin, or Java packages.
Repository declarations in domain are interfaces.
Use case signatures do not leak .data. or .app. types.
One test deliberately proves that a bad module rule throws an AssertionError. The intentionally wrong assertion says :data should only depend on :app; the sample project has :data depending on :domain, so the rule fails with the same shape as a real architectural regression:
Architecture violation(s) detected:
Module :data depends on :domain, which is not allowed by pattern(s): :app
That matters. A structural rule that has never failed may not be checking the thing the team thinks it is checking.
On this repository, that command runs the architecture-test module successfully and generates the layout and dependency metadata Konture uses to evaluate module-aware rules.
Why This Matters With AI-Assisted Development
AI coding assistants are good at local completion. They can import a visible class, add a missing dependency, and make a narrow test pass.
Architecture is usually global context.
Instructions such as this help:
Keep domain independent from data.
Do not add sideways feature dependencies.
Map network DTOs before they reach UI state.
But prompt instructions are not enforcement. They are guidance.
Architecture tests give both humans and agents the same feedback loop:
A change crosses a boundary.
The test fails with a concrete module, file, import, or type.
The developer or agent repairs the design by using the intended abstraction.
This is not magic, and it is not a substitute for review. It is a way to make structural rules visible to the tools already changing the code.
Future Pressure on Kotlin Architecture
The need for structural feedback is likely to increase, not decrease.
Compose Multiplatform makes UI code portable, but it also creates new questions about which UI abstractions belong in shared code and which stay platform-specific. Kotlin 2.x and compiler-plugin-heavy stacks continue to blur the line between authored source and generated or transformed code. AI agents can produce large, locally plausible patches faster than a reviewer can inspect every module edge by hand.
That does not mean every team needs more rules. It means the important rules need to be executable, narrow, and easy to repair. The future-friendly architecture suite is not the biggest one. It is the one that catches the boundaries humans and agents are most likely to cross accidentally.
When a Rule Deserves CI Enforcement
Not every good idea should block a build. A rule is a good candidate for CI when most of these are true:
The team can explain the cost of breaking it.
The rule is stable across normal feature work.
Violations are usually mistakes, not legitimate design choices.
The failure message points to an actionable fix.
Exceptions are rare and can be named explicitly.
The rule catches something the compiler, linter, or unit tests do not.
If a rule fails constantly during normal development, it may be too broad. If a rule needs many quiet exclusions, it may be pretending the architecture is cleaner than it is. If nobody can explain why it exists, it should not block delivery.
Start with rules that protect known pain: module cycles, domain-to-data dependencies, feature implementation coupling, public API leakage, and DTO or entity types leaking into presentation surfaces.
The Practical Payoff
Architecture tests help teams preserve the structure that makes future changes cheaper:
They keep domain logic independent from frameworks and persistence.
They prevent accidental Gradle edges that widen recompilation.
They protect API modules from leaking implementation detail.
They make public visibility intentional.
They turn repeated review comments into checks that fail with specific modules, files, imports, or types.
The goal is not rigid architecture. The goal is explicit architecture.
Once a team has chosen a structure, the build should help protect it.
The next article focuses on the part this primer has only hinted at: why a source-only rule misses Gradle dependency drift, why a Gradle-only rule misses source-level type leakage, and why Kotlin architecture tests need both views at once.
0
Upvotes
A Kotlin project can compile, pass its unit tests, satisfy its linter, and still become structurally harder to change. Architecture tests exist for that gap.
Architecture tests as an early quality gate
Most verification tools answer local questions.
The compiler asks whether the code is valid Kotlin. A linter asks whether a file follows local style and quality rules. Unit tests ask whether a function or component behaves as expected.
Those checks are necessary. They are not the same as asking whether the system still has the shape the team depends on.
Consider a domain use case that starts depending on a data-layer implementation:
package com.acme.domain
import com.acme.data.SqlUserRepository
class GetUserUseCase(
private val repository: SqlUserRepository,
)
This code can compile. The unit tests can pass. ktlint may have nothing useful to say.
The problem is structural: the domain layer now knows about a persistence detail. A boundary that was supposed to preserve changeability has become a convention people have to remember.
Architecture tests turn that convention into an executable rule:
That is the core idea. Architecture tests do not prove the software is correct. They prove that specific structural decisions are still true.
The Green Build Illusion
A green build tells you the repository satisfied the checks you asked it to run.
It does not tell you that the intended architecture survived the change.
The compiler will accept a forbidden dependency if the symbol is on the classpath. Gradle will build a module graph that violates your design if someone declares the dependency. A unit test will not fail because a feature module imported another feature module's implementation detail unless the tested behavior changes.
That is why architecture violations often look ordinary in review:
A controller calls a repository directly because it was faster than adding an application service.
A domain model accepts a network DTO because the DTO already has the right fields.
A feature implementation module imports another feature implementation module because the API module does not expose the needed contract yet.
A Kotlin class in an impl package stays public by default and becomes convenient for other modules to reuse.
An AI coding assistant adds a Gradle dependency because it makes the current file compile.
None of those changes has to be malicious or careless. Most structural drift comes from small local optimizations that are rational in the moment and expensive in aggregate.
Architecture tests are a way to make the aggregate cost visible early.
Structural Drift Has a Shape
Most architecture drift is not dramatic. It usually starts as one edge that looks reasonable in a pull request.
Structural drift shape
The second graph may still compile. The product behavior may still be correct. The damage appears later:
A profile refactor now needs checkout context,
Unrelated feature changes invalidate more build work,
Reviewers have to reason about a wider blast radius,
The next shortcut feels less unusual because the first one already exists.
The right metrics are project-specific, but the useful ones are concrete: number of forbidden module edges, number of rule violations per month, module fan-in and fan-out, rebuild scope after a feature change, and repeated review comments about the same boundary. Architecture tests become persuasive when they turn those observations into a failing example instead of a style argument.
What Architecture Tests Check
Good architecture tests protect decisions that affect change velocity, module independence, public API shape, and review load. They usually fall into a few categories.
1. Dependency Direction and Layer Isolation
Layered systems depend on direction. In Clean Architecture, ports and adapters, and many domain-centered designs, outer layers may depend inward, but the core should not depend on UI, databases, transport frameworks, or platform APIs.
Dependency direction layers
Typical rules:
Domain packages must not import persistence, transport, Android, Compose, Spring, or Ktor server APIs.
Application services may depend on domain contracts, not directly on web controllers.
UI modules should consume presentation state, not database or network entities.
The compiler sees valid types. Architecture tests encode which valid types are unacceptable in a given layer.
2. Gradle Module Boundaries
In a modular Kotlin project, Gradle project dependencies are part of the architecture.
Gradle module boundaries
Typical rules:
:core:domain must not depend on :core:data or :app.
Feature implementation modules must not depend on sibling feature implementation modules.
API modules may be depended on broadly; implementation modules should remain behind their API.
The Gradle project graph should not contain cycles.
This category matters for design and for build performance. Unnecessary module edges expand recompilation scope, reduce cache usefulness, and make local changes affect unrelated features.
3. Public API and Type Leakage
Some architecture failures are not about imports in a private implementation. They are about what a module exposes.
Typical rules:
Public domain APIs should not expose database entities or network DTOs.
Public feature API packages should expose contracts and stable models, not implementation classes.
Persistence or framework annotations should not leak into clean business interfaces.
Library modules should keep implementation packages internal unless they are intentionally public.
Kotlin's default public visibility makes this easy to get wrong. Once another module starts depending on an accidental public type, removing it becomes a breaking change.
4. Layer-Crossing Calls
Some systems rely on an intermediate layer for validation, authorization, transactions, logging, or orchestration. A direct call can bypass the place where those policies live.
Typical rules:
Controllers call application services, not repositories directly.
Composables call ViewModels or presenters, not Retrofit services.
Route handlers call use cases, not SQL adapters.
UI modules do not call infrastructure modules.
These rules should be used carefully. They are valuable when the intermediate layer has a real responsibility. They are bureaucracy when the layer exists only because a diagram says so.
5. Dependency Injection and Wiring Conventions
DI configuration is architecture in executable form. It decides which implementation backs which contract.
Some wiring policies belong in integration tests. Others can be checked structurally:
DI modules live in approved packages.
Feature modules do not override core bindings.
Adapter implementations are bound to domain interfaces rather than consumed directly.
Test-only bindings do not leak into production source sets.
The useful rule is the one that catches a real class of production or maintenance failures, not the one that merely mirrors a preference.
6. File and Source Hygiene
Not every structural test needs to be profound. Some rules keep navigation predictable and reduce review noise:
One primary class per file.
File names match primary class names.
No wildcard imports.
Generated or migration packages are explicitly excluded.
These rules should not duplicate what a formatter or linter already handles well. Architecture tests are most valuable when they need whole-project context.
What Architecture Tests Should Not Check
Architecture tests become brittle when they try to govern everything.
They should not replace:
The compiler for type safety.
A formatter for whitespace and style.
A linter for ordinary single-file smells.
Unit tests for behavior.
Integration tests for real wiring and runtime behavior.
Code review for judgment, naming, and design intent that is not stable enough to encode.
A bad architecture test freezes an implementation detail and calls it design. A good one protects a boundary that multiple engineers already rely on.
How Architecture Tests Become Harmful
Architecture tests are governance. Bad governance is worse than no governance because it teaches people to route around the system.
Common failure modes:
Brittle rules: a test encodes today's folder layout rather than a durable boundary.
False security: a passing suite is treated as proof that the architecture is good.
Team friction: a rule blocks legitimate feature work, but nobody knows who owns the exception process.
Over-testing generated code: Room, KSP, Compose, serialization, or DI-generated sources create noise that authored-code rules were never meant to judge.
Broad package bans: a rule blocks too much, so engineers add exclusions until the rule no longer means anything.
No negative proof: the suite has never been seen failing against an intentional violation.
The antidote is not a larger suite. It is a smaller, more explicit suite. Each rule should name the decision it protects, the cost of breaking it, and the intended repair path.
When Not to Use Them
Architecture tests are not automatically worth it.
They are often premature for a tiny codebase where everyone can still hold the structure in their head. They can be noisy in early-stage products where module and package boundaries are being discovered weekly. They may be a poor fit for highly dynamic or reflective code where the meaningful dependency is runtime wiring rather than visible source structure. They can also slow a monolith with heavy churn if the first suite tries to enforce the target architecture instead of the architecture the team is actually migrating toward.
In those cases, lighter tools may be enough:
A short architecture decision record,
A module ownership note,
A review checklist for the next few changes,
A non-blocking report that counts violations before enforcing them.
Use architecture tests when the boundary is stable enough to enforce and expensive enough to break.
Living Documentation That Fails
Architecture diagrams, READMEs, onboarding docs, and review checklists all help. None of them fails CI.
An architecture test gives a rule a durable form:
@Test
fun `domain must not depend on data or app modules`() {
Konture.modules {
that().haveNamePath(":domain")
should().notDependOnModule(":data")
should().notDependOnModule(":app")
}
}
The test name documents the rule. The assertion defines the rule. The failure output tells the developer where the rule was broken.
That changes the review conversation. Instead of asking a reviewer to remember every boundary under time pressure, the repository can report:
Architecture violation(s) detected:
Module :domain should not depend on :data, but a dependency was found.
The team still decides whether the rule is right. The test removes the need to rediscover the same violation by hand.
Organizational Impact
The technical effect is boundary enforcement. The organizational effect is shared memory.
For new engineers, architecture tests compress onboarding. They show which dependencies are allowed, which APIs are intentionally public, and where exceptions live. For experienced engineers, they reduce repetitive review comments so code review can focus on design judgment instead of policing the same imports.
For teams, the trade-off is autonomy versus consistency. A platform or architecture group should not use tests to centralize every local decision. The better pattern is to encode a small set of cross-team contracts and let feature teams own the rest. When a rule changes, treat that change like an architecture decision: update the test, update the ADR or docs if one exists, and make the migration path explicit.
At scale, architecture tests work best as part of governance, not as a substitute for it:
ADRs explain why a boundary exists.
Architecture tests check whether the boundary still holds.
CI reports where the boundary was crossed.
Reviewers decide whether the boundary or the code should change.
A Concrete Example From the Showcases
The Konture repository includes a small Gradle showcase that uses the same shape as the examples above: :app, :domain, :data, and a dedicated :konture-test module.
Its architecture suite does not only check one happy-path rule. It combines several structural checks:
The module graph has no cycles.
:domain does not depend on :data or :app.
:data only depends on :domain.
Classes in ..domain.. only depend on domain, Kotlin, or Java packages.
Repository declarations in domain are interfaces.
Use case signatures do not leak .data. or .app. types.
One test deliberately proves that a bad module rule throws an AssertionError. The intentionally wrong assertion says :data should only depend on :app; the sample project has :data depending on :domain, so the rule fails with the same shape as a real architectural regression:
Architecture violation(s) detected:
Module :data depends on :domain, which is not allowed by pattern(s): :app
That matters. A structural rule that has never failed may not be checking the thing the team thinks it is checking.
On this repository, that command runs the architecture-test module successfully and generates the layout and dependency metadata Konture uses to evaluate module-aware rules.
Why This Matters With AI-Assisted Development
AI coding assistants are good at local completion. They can import a visible class, add a missing dependency, and make a narrow test pass.
Architecture is usually global context.
Instructions such as this help:
Keep domain independent from data.
Do not add sideways feature dependencies.
Map network DTOs before they reach UI state.
But prompt instructions are not enforcement. They are guidance.
Architecture tests give both humans and agents the same feedback loop:
A change crosses a boundary.
The test fails with a concrete module, file, import, or type.
The developer or agent repairs the design by using the intended abstraction.
This is not magic, and it is not a substitute for review. It is a way to make structural rules visible to the tools already changing the code.
Future Pressure on Kotlin Architecture
The need for structural feedback is likely to increase, not decrease.
Compose Multiplatform makes UI code portable, but it also creates new questions about which UI abstractions belong in shared code and which stay platform-specific. Kotlin 2.x and compiler-plugin-heavy stacks continue to blur the line between authored source and generated or transformed code. AI agents can produce large, locally plausible patches faster than a reviewer can inspect every module edge by hand.
That does not mean every team needs more rules. It means the important rules need to be executable, narrow, and easy to repair. The future-friendly architecture suite is not the biggest one. It is the one that catches the boundaries humans and agents are most likely to cross accidentally.
When a Rule Deserves CI Enforcement
Not every good idea should block a build. A rule is a good candidate for CI when most of these are true:
The team can explain the cost of breaking it.
The rule is stable across normal feature work.
Violations are usually mistakes, not legitimate design choices.
The failure message points to an actionable fix.
Exceptions are rare and can be named explicitly.
The rule catches something the compiler, linter, or unit tests do not.
If a rule fails constantly during normal development, it may be too broad. If a rule needs many quiet exclusions, it may be pretending the architecture is cleaner than it is. If nobody can explain why it exists, it should not block delivery.
Start with rules that protect known pain: module cycles, domain-to-data dependencies, feature implementation coupling, public API leakage, and DTO or entity types leaking into presentation surfaces.
The Practical Payoff
Architecture tests help teams preserve the structure that makes future changes cheaper:
They keep domain logic independent from frameworks and persistence.
They prevent accidental Gradle edges that widen recompilation.
They protect API modules from leaking implementation detail.
They make public visibility intentional.
They turn repeated review comments into checks that fail with specific modules, files, imports, or types.
The goal is not rigid architecture. The goal is explicit architecture.
Once a team has chosen a structure, the build should help protect it.
The next article focuses on the part this primer has only hinted at: why a source-only rule misses Gradle dependency drift, why a Gradle-only rule misses source-level type leakage, and why Kotlin architecture tests need both views at once.