Guardrails for AI-Generated Kotlin with detekt and ktlint
Turn your coding standards into checks that both developers and AI agents must pass

Search for a command to run...
Turn your coding standards into checks that both developers and AI agents must pass

No comments yet. Be the first to comment.
Sometimes you need to start several pieces of work at the same time and handle each result as soon as it is available. The fan-in pattern is a good fit for this: many concurrent producers send their r

If you own a modern Mac with an Apple Silicon chip and Apple Intelligence enabled, then you already have access to a local foundation model. There is no separate model to download, no API key to creat

In looking at enabling virtual threads for a Spring Boot application, I began to wonder how many virtual threads I could spawn compared to platform threads. I knew the answer would be "many more", but

Sometimes you need to run work in the background of a Spring Boot application. This may be work that should not block the HTTP request that triggered it, or work that is started by a scheduled process

Henry Brown's Notes on the JVM
5 posts
Practical notes on software development for the JVM, from Cape Town. Mostly Kotlin and Spring Boot: coroutines and structured concurrency, virtual threads, reactive programming, and the occasional Git trick or experiment with local AI models. Every post starts with something I actually needed to build, debug, or measure — so expect working code, real numbers, and honest trade-offs rather than theory. I also cover some of this on YouTube. All views are my own.
When we adopt agentic coding workflows, our job starts to shift from typing every line of code to defining the conditions that generated code must satisfy.
An AI coding agent can produce a working implementation very quickly. That does not mean the implementation follows the conventions of the project, avoids known code smells, or uses Kotlin in the way the team has agreed. A prompt such as “follow our coding standards” helps, but it is still only an instruction expressed in natural language. The agent may interpret it differently from one task to the next.
The stronger approach is to make those standards executable. In this post I will add two guardrails to a Spring Boot 4 project written in Kotlin:
These tools do not make generated code correct, secure, or well designed on their own. Tests, code review, architecture and human judgement still matter. What they do is turn a useful subset of the team's expectations into fast, repeatable checks that can run after every change.
The complete example is available in the springboot4-detekt-demo repository.
An agent does not work from the task description alone. It also reads nearby classes, tests, build files and configuration to infer how the project is organised. The existing codebase therefore becomes part of the prompt, whether we intend it to or not.
If the repository contains several ways to name the same concept, inconsistent formatting and multiple patterns for solving the same problem, the agent has weak examples to follow. It may reproduce any of them or introduce yet another variation. If the repository is consistent, the agent has a much clearer signal.
This is similar to the broken windows theory: visible disorder can suggest that further disorder will be tolerated. In a codebase, one ignored warning or one “temporary” exception is not likely to cause a disaster. The problem is accumulation. Once inconsistent code becomes normal, both people and agents use it as precedent for the next change.
There are therefore two complementary ways to guide an agent:
The first shapes what the agent is likely to generate. The second verifies what it actually generated. That feedback loop is the guardrail: generate a change, run the checks, inspect the failures, correct the change and repeat until the build is clean.
There is some overlap between the tools, but they solve different problems.
ktlint is primarily concerned with the shape of Kotlin code. It enforces matters such as indentation, whitespace, wrapping and imports, and it can automatically fix many violations. This removes formatting debates from prompts and code reviews because the project carries its style in configuration rather than in personal IDE settings.
detekt performs static analysis.
Its rule sets can identify complexity, naming problems, risky exception handling, potential bugs and Kotlin-specific code smells.
It is also configurable, so the rules can express decisions that matter in a particular project—for example, prohibiting GlobalScope or preferring delay over Thread.sleep in coroutine code.
Together they provide a useful division of responsibility:
Both tools are deterministic. The same source and configuration produce the same findings regardless of whether the code was written by a person or generated by an agent.
The example project uses a Gradle version catalog.
Add the detekt and ktlint Gradle plugin versions to the [versions] section of gradle/libs.versions.toml:
detekt = "2.0.0-alpha.6"
ktlint-gradle = "14.2.0"
The demo uses detekt 2.0 because Spring Boot 4 and recent Kotlin versions require a compatible analysis toolchain. At the time of writing, detekt 2.0 is still an alpha release, so check the detekt compatibility table and release notes before choosing a version for your own project.
The second version is for the ktlint Gradle plugin, which wraps ktlint and creates convenient Gradle check and format tasks.
The version catalog also needs plugin aliases that map these versions to the dev.detekt and org.jlleitschuh.gradle.ktlint plugin ids.
Those mappings are present in the complete demo repository.
Apply both aliases in build.gradle.kts:
plugins {
// ...
alias(libs.plugins.detekt)
alias(libs.plugins.ktlint)
}
Once applied, the plugins add their tasks to the Gradle build.
The important tasks for this article are detekt, ktlintCheck and ktlintFormat.
Next, configure both plugins in build.gradle.kts:
detekt {
buildUponDefaultConfig = true
allRules = false
parallel = true
ignoreFailures = false
autoCorrect = false
}
ktlint {
verbose.set(true)
outputToConsole.set(true)
ignoreFailures.set(false)
enableExperimentalRules.set(false)
coloredOutput.set(true)
additionalEditorconfig.set(
mapOf(
"ktlint_code_style" to "intellij_idea",
"max_line_length" to "120",
),
)
reporters {
reporter(ReporterType.PLAIN)
reporter(ReporterType.CHECKSTYLE)
}
filter {
exclude("**/generated/**")
exclude("**/build/**")
include("**/kotlin/**")
include("*.gradle.kts")
}
}
There are a few deliberate decisions in this configuration.
For detekt, buildUponDefaultConfig = true starts with detekt's defaults and lets the project override only the rules it cares about.
allRules = false avoids enabling every optional rule simply because it exists.
That is important: a guardrail should reflect an intentional team decision, not create a wall of findings that everybody learns to ignore.
ignoreFailures = false is what makes a finding enforceable.
The Gradle task fails instead of printing a warning and allowing the build to continue.
autoCorrect = false also keeps static-analysis changes explicit.
Unlike formatting, many detekt findings require a design decision and should not be rewritten blindly.
The ktlint configuration follows the same fail-the-build approach. It prints detailed findings to the console and produces plain and Checkstyle reports. The filter excludes generated output and includes Kotlin source and Gradle Kotlin scripts, so the tool checks code that the team owns rather than generated code that will be overwritten.
The style values are supplied here as additional EditorConfig properties.
Later we will put the same project-wide decisions in .editorconfig, where IDEs and other compatible tools can also discover them.
A quality task that nobody remembers to run is documentation, not a guardrail.
Connect both checks to Gradle's standard check lifecycle and add a memorable formatting alias:
tasks.named("check") {
dependsOn("detekt", "ktlintCheck")
}
tasks.register("format") {
group = "formatting"
description = "Formats Kotlin source and Gradle Kotlin scripts."
dependsOn("ktlintFormat")
}
Now the same command used for tests also evaluates the static-analysis and formatting rules.
The format task gives developers and agents a simple way to apply ktlint's safe formatting corrections before checking again.
With the configuration in place, the normal workflow is:
./gradlew check../gradlew detekt../gradlew ktlintCheck../gradlew format or ./gradlew ktlintFormat.I would instruct an AI agent to run ./gradlew format after editing Kotlin, followed by ./gradlew check before it considers the task complete.
The first command handles mechanical formatting.
The second provides the actual acceptance gate, including tests and findings that need judgement.
Run the same ./gradlew check command in continuous integration.
That prevents a local tool configuration, an IDE setting or an agent that skipped validation from becoming a way around the rules.
The default rules are a good start, but the most valuable guardrails describe decisions that are specific to the project.
detekt supports a YAML configuration file for enabling rules and changing their options.
You can generate a starting file with ./gradlew detektGenerateConfig, then keep it under version control.
The detekt configuration guide describes the file structure, validation and ways to maintain a smaller override file on top of the defaults.
For example, a Spring Boot application that uses coroutines can enable the following rules:
coroutines:
active: true
GlobalCoroutineUsage:
active: true
InjectDispatcher:
active: true
dispatcherNames:
- IO
- Default
- Unconfined
RedundantSuspendModifier:
active: true
SleepInsteadOfDelay:
active: true
SuspendFunSwallowedCancellation:
active: true
SuspendFunWithCoroutineScopeReceiver:
active: true
These rules make several coroutine expectations executable. They can catch global coroutine usage, hard-coded dispatchers, blocking sleeps in coroutine code, swallowed cancellation and unnecessary or misleading suspend APIs. The result is more useful than telling an agent to “use coroutines correctly”, because a violation produces a concrete rule id and source location that it can act on.
Save the configuration as config/detekt/detekt.yml and point the detekt Gradle extension at it with config.setFrom(files("$rootDir/config/detekt/detekt.yml")).
In the linked demo repository that line is present but commented out; uncomment it when you want these project-specific rules to take effect.
Without that reference, detekt continues to use its default configuration.
The demo's full configuration also covers complexity, exception handling, naming, potential bugs and style. Treat it as a starting point rather than a universal standard. Enable rules deliberately, agree on legitimate exceptions and tune thresholds to the codebase. A small set of trusted checks is more effective than a large set that developers routinely suppress.
For an existing project, consider introducing strict rules gradually. detekt supports baselines, which record existing findings so the build can reject new violations without requiring a large clean-up before the first adoption. The baseline should be a migration tool, not a permanent hiding place: review it and reduce it as the code improves.
.editorconfigAn .editorconfig file is a plain-text project configuration file for formatting conventions.
Editors and IDEs that support EditorConfig discover it automatically, which allows team members to use different tools while sharing the same basic rules.
Place the file near the repository root and commit it to version control:
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{kt,kts}]
indent_style = space
indent_size = 4
continuation_indent_size = 4
max_line_length = 120
ktlint_code_style = intellij_idea
[*.yml]
indent_style = space
indent_size = 2
The general section standardises encoding, line endings, final newlines and trailing whitespace. The Kotlin section selects four-space indentation, a 120-character line length and ktlint's IntelliJ IDEA code style. The YAML section uses two-space indentation.
ktlint reads its rule properties from EditorConfig, so the file is more than an IDE preference. It is a versioned formatting contract that the Gradle task can enforce. The ktlint rule documentation lists the standard rules and their EditorConfig properties, while the ktlint Gradle plugin documentation explains task configuration, filtering, reporters and baselines.
Keeping the formatting rules with the source prevents unnecessary formatting-only changes and makes reviews easier to read. It also means an agent can format its own changes with the same rules used by every developer and by CI.
After this setup, the build becomes part of the agent's working instructions. A useful task-level instruction can be as simple as:
After changing Kotlin code, run
./gradlew formatand then./gradlew check. Fix any detekt, ktlint or test failures before finishing. Do not suppress a rule unless the reason is explained.
This works because the instruction points to project-owned tools instead of trying to repeat every coding rule in the prompt. When the standards change, update the repository configuration once. Developers, CI and AI agents all receive the same new contract.
The order matters as well.
Formatting first removes mechanical noise, then check evaluates the formatted result.
If detekt reports a design smell, the agent should fix the underlying code and run the checks again.
A suppression should be a conscious exception reviewed in the same way as any other design decision, not the quickest route to a green build.
The quality of AI-generated code is influenced by the environment in which the agent works. A clean and consistent repository gives it better examples, while executable quality checks give it clear boundaries. You need both.
In this Spring Boot 4 project, ktlint makes formatting predictable and automatically corrects many style issues.
detekt catches code smells and lets the project encode more specific Kotlin and coroutine decisions.
Connecting both to ./gradlew check turns them from optional tools into an acceptance gate that applies equally to human-written and generated code.
Guardrails do not replace review, testing or engineering judgement. They make that judgement reusable. Instead of explaining the same expectation in every prompt and every pull request, you record it in the project and let fast, deterministic tooling provide feedback whenever the code starts to drift.
The full source code is available at github.com/hgbrown/springboot4-detekt-demo.