# Guardrails for AI-Generated Kotlin with detekt and ktlint

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:

1. [detekt](https://detekt.dev/) for static analysis and code-smell detection.
2. [ktlint](https://ktlint.github.io/ktlint/) for consistent Kotlin formatting and style.

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](https://github.com/hgbrown/springboot4-detekt-demo).

## Why the existing codebase matters

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:

1. Give it good examples in the existing code.
2. Give it automated checks that reject code which falls outside the agreed boundaries.

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.

## What detekt and ktlint each contribute

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:

- ktlint answers, “Is this code formatted according to the project style?”
- detekt answers, “Does this code contain patterns that the project has decided to reject?”

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.

## Step 1: Add the plugin versions

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`:

```yaml
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](https://detekt.dev/docs/introduction/compatibility/) and [release notes](https://detekt.dev/changelog-2.0.0/) before choosing a version for your own project.

The second version is for the [ktlint Gradle plugin](https://github.com/JLLeitschuh/ktlint-gradle), 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.

## Step 2: Apply the plugins

Apply both aliases in `build.gradle.kts`:

```kotlin
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`.

## Step 3: Configure the guardrails

Next, configure both plugins in `build.gradle.kts`:

```kotlin
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.

## Step 4: Make the checks part of the normal build

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:

```kotlin
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:

- Run everything with `./gradlew check`.
- Run static analysis only with `./gradlew detekt`.
- Check formatting only with `./gradlew ktlintCheck`.
- Apply ktlint's automatic formatting with `./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.

## Step 5: Add project-specific detekt 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](https://detekt.dev/docs/introduction/configurations/) 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:

```yaml
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](https://detekt.dev/docs/introduction/baseline/), 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.

## Step 6: Put the formatting contract in `.editorconfig`

An `.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:

```yaml
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](https://ktlint.github.io/ktlint/latest/rules/standard/) lists the standard rules and their EditorConfig properties, while the [ktlint Gradle plugin documentation](https://github.com/JLLeitschuh/ktlint-gradle) 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.

## The feedback loop for an AI coding agent

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 format` and 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.

## Conclusion

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](https://github.com/hgbrown/springboot4-detekt-demo).
