Skip to content
Back to skills

Konsist Architecture Tests

ASecurity

Use when adding or extending automated module-boundary / architecture enforcement in a Kotlin or Kotlin Multiplatform Gradle project — rules like "core must not depend on feature", "api must not leak impl", layer/package dependency directions that should fail the build the moment a bad import is written. Covers tool choice (Konsist vs ArchUnit/Detekt), the scan-from-disk scaffold, and verifying a rule actually bites.

  • 10 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added August 31, 2026
developmentgokotlinbashapi

Works with

  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned August 31, 2026

npx -y skills add ShipWithAI/shipwithai-plugins --skill konsist-architecture-tests --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Konsist Architecture Tests?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Konsist Architecture Tests
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/shipwithai-konsist-architecture-tests/badge)](https://www.skillsdirectory.com/skills/shipwithai-konsist-architecture-tests)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: konsist-architecture-tests
description: Use when adding or extending automated module-boundary / architecture enforcement in a Kotlin or Kotlin Multiplatform Gradle project — rules like "core must not depend on feature", "api must not leak impl", layer/package dependency directions that should fail the build the moment a bad import is written. Covers tool choice (Konsist vs ArchUnit/Detekt), the scan-from-disk scaffold, and verifying a rule actually bites.
---

# Konsist architecture tests

## Overview

Enforce module-boundary invariants as **plain JVM unit tests** with [Konsist](https://docs.konsist.lemonappdev.com), which parses Kotlin source straight from disk. Each violation turns the suite red the moment a forbidding `import` is written — before review, before the dependency graph is even wired. The check is on **imports** (source-level), the right altitude: a stray `dependencies {}` edge can sit unnoticed, but the first illegal `import` that relies on it fails.

## Why Konsist — not ArchUnit or Detekt

This is the non-obvious decision; get it right first.

- **ArchUnit** reads compiled **bytecode** off a classpath. A pure `commonMain` / `iosMain` KMP module never hands a JVM test task its bytecode, so ArchUnit can't see that code at all. Konsist reads **source**, so it sees every source set.
- **Detekt** wants a Gradle plugin per module; on `com.android.kotlin.multiplatform.library` (AGP 9) plugin-compat lags and KMP source-set wiring is awkward.
- **Konsist** needs no plugin and no classpath: `Konsist.scopeFromProject()` walks the whole repo from a single host test — the same "rule as a host test" shape as other structural checks. One prevention mechanism, not two.

## Where it lives + wiring

Put it in one JVM `test` source set that runs `testDebugUnitTest` — conventionally the **app module** (it already depends on everything, but Konsist scans the whole project from disk regardless of which module hosts the test):

```
app/src/test/kotlin/<base.package>/architecture/ArchitectureTest.kt
```

Add the dependency to that module: `testImplementation(libs.konsist)` (catalog: `konsist = { module = "com.lemonappdev:konsist", version.ref = "konsist" }`). Run just these rules:

```bash
./gradlew :app:testDebugUnitTest --tests "<base.package>.architecture.ArchitectureTest"
```

## Survey the graph, then write one rule per edge

Read `settings.gradle(.kts)` and the module folders. For each *intended* dependency direction (`core ← feature`, `api` is a seam over `impl`, `domain ↛ data`, …), write one `@Test`. Don't invent rules the project doesn't hold.

## The canonical scaffold

Rules key on the **on-disk Gradle module path** (`core/audio`, `feature/foo/api`) derived from each file's `projectPath`, over **production source only** — never on bytecode, and never only on package names. Three helpers carry that; copy them verbatim:

```kotlin
package com.example.app.architecture

import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.declaration.KoFileDeclaration
import com.lemonappdev.konsist.api.verify.assertFalse
import org.junit.Test

class ArchitectureTest {

    private val files: List<KoFileDeclaration> = Konsist.scopeFromProject().files

    /** Core modules are foundational — they must never depend on a feature. */
    @Test
    fun `core does not depend on feature`() {
        files
            .filter { it.isProductionSource() && it.modulePath().startsWith("core/") }
            .assertFalse { file ->
                file.hasImport { it.name.startsWith("com.example.app.feature.") }
            }
    }

    /** A feature's public `api` contract must not leak its own `impl` internals. */
    @Test
    fun `feature api does not depend on feature impl`() {
        files
            .filter {
                it.isProductionSource() &&
                    it.modulePath().startsWith("feature/") &&
                    it.modulePath().endsWith("/api")
            }
            .assertFalse { file ->
                file.hasImport {
                    it.name.startsWith("com.example.app.feature.") && it.name.contains(".impl.")
                }
            }
    }
}

/** Source-set folder (the path segment after `src/`); null for build/generated or non-source files. */
private fun KoFileDeclaration.sourceSet(): String? {
    val segs = projectPath.trimStart('/').split('/')
    if ("build" in segs) return null
    val i = segs.indexOf("src")
    return if (i >= 0) segs.getOrNull(i + 1) else null
}

/** True for main/production source sets; excludes test, commonTest, androidHostTest, etc. */
private fun KoFileDeclaration.isProductionSource(): Boolean {
    val ss = sourceSet() ?: return false
    return ss != "test" && !ss.endsWith("Test")
}

/** Gradle module path = everything before /src/, e.g. feature/chord-smoother/impl. */
private fun KoFileDeclaration.modulePath(): String =
    projectPath.trimStart('/').substringBefore("/src/")
```

## Adding a rule (the recipe)

1. `filter` to `isProductionSource()` **and** the modules the rule governs, by `modulePath()`.
2. `assertFalse { file.hasImport { it.name.startsWith("<forbidden import prefix>") } }`.
3. Name the `@Test` after the invariant, in backticks.

The `.impl.` import-substring check is enough **when packages encode the module** (the common case: `feature/foo/impl` → `…feature.foo.impl.*`). Only if packages don't encode the module — or you must forbid importing *another* feature's impl — pair `api`↔`impl` by module-path feature name. Don't reach for that extra machinery by default; it's complexity the simple check doesn't need.

## Verify the rule BITES — required

A green suite proves nothing until you have watched it go red. After adding a rule, **plant a violation**: add the forbidden `import` in a governed production file, run the test, and confirm it FAILS *naming that file*. Then revert and confirm green. Skip this and a misfiltered rule (wrong `modulePath` prefix, scanning test source, a typo'd package prefix) silently filters to **zero files** and passes forever — enforcing nothing while looking enforced.

**Back the one-time check with a permanent guard.** The planted-violation test is manual and runs once; add a rule that *stays* in the suite and fails the day a selector silently matches zero files:

```kotlin
@Test
fun `boundary selectors match real files (guard against vacuous pass)`() {
    val core = files.filter { it.isProductionSource() && it.modulePath().startsWith("core/") }
    val featureApi = files.filter {
        it.isProductionSource() && it.modulePath().startsWith("feature/") && it.modulePath().endsWith("/api")
    }
    assert(core.isNotEmpty()) { "core/ selector matched 0 files — prefix or source-set filter is wrong" }
    assert(featureApi.isNotEmpty()) { "feature/**/api selector matched 0 files — selector is wrong" }
}
```

## Common mistakes

| Mistake | Symptom | Fix |
|---|---|---|
| Scanning all files | test-only imports trip the rule | filter `isProductionSource()` |
| Keying on package names only | misses kebab-case modules, wrong pairing | derive `modulePath()` from `projectPath` |
| Never planting a violation | rule filters to 0 files, passes forever | the BITES step above |
| ArchUnit on a KMP module | `commonMain`/`iosMain` code invisible | Konsist (reads source, not bytecode) |
| Reinventing api↔impl pairing | fragile extra helpers | the `.impl.` substring check suffices when packages encode the module |

Files in this skill

  • SKILL.md7.3 KB
  • evals.json1.8 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.