Detekt Explained: Enhance Kotlin Projects with Static Analysis - part 1
Practical insights and step-by-step usage.

Static analysis isn't about making your code "look nice." It's about catching the kind of problems that quietly slow teams down: hidden complexity, fragile logic, and maintainability debt.
In most Android codebases, quality doesn't degrade because engineers don't care—it degrades because there's no consistent, automated feedback loop.
That's where detekt comes in.
This article kicks off a practical series on using detekt in real projects—from zero setup to custom rules and CI enforcement.
In this first part, we'll cover:
- Why Detekt Exists
- What Makes Detekt Different
- How Detekt Works
- Rules and Rule Sets
- Extensibility
- Production-Ready Setup
Why Detekt Exists
When Kotlin 1.0 was released in 2016, it solved many problems—null safety, conciseness, better readability.
But it created a new one: tooling.
Java teams relied on Checkstyle, PMD, and FindBugs — none of which translated to Kotlin. Checkstyle physically can't parse .kt files. PMD took seven years to add experimental Kotlin support, held back by compiler instability and the cost of building custom ANTLR parsers. FindBugs died in 2015, before Kotlin 1.0 was released.
FindBugs could technically handle Kotlin because it operates on Java bytecode which has the same format as Kotlin bytecode. The problem is FindBugs will give false positives because Kotlin provides advanced structural features that do not natively exist in Java (null safety, data classes, coroutines…)
Let's take the example of a Kotlin data class.
Kotlin:
data class User(val name: String, val age: Int)
Java bytecode representation:
public final class User {
private final String name;
private final int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
// Synthesized component methods for destructuring
public final String component1() { return this.name; }
public final int component2() { return this.age; }
// Synthesized copy mechanism
public final User copy(String name, int age) { return new User(name, age); }
}
FindBugs sees component1(), component2(), copy() as suspicious methods with no obvious call sites in the code — because they're used via Kotlin destructuring which compiles away. It would flag them as dead code or unused methods. That's the false positive.
The gap Artur Bosch filled
Kotlin 1.0 launched in 2016. Detekt also started in 2016. In 2016, the Android and Kotlin ecosystem was at a massive turning point. The landscape was predominantly Java-based, but the official release of Kotlin 1.0 in February sparked a quiet revolution among developers eager to escape Java's verbosity and NullPointerExceptions. It was the right time for a tool like detekt to emerge — it was a bet. If Kotlin became a success, it would become the first Kotlin static analyzer tool in the space.
JetBrains was focused on adoption, not tooling — which left room for the community to fill the gap. Detekt was created by Artur Bosch in 2016 to fill that gap: a static analysis tool built specifically for Kotlin.
Today:
It has hundreds of contributors
It's actively maintained
It's widely adopted across serious Kotlin codebases
Sponsors include Emerge, CodeRabbit, PostHog, American Express, and Doist.
More importantly, it has survived Kotlin's evolution — which is the real signal of maturity.
How detekt survived for 10 years
Kotlin has evolved from a niche JVM language designed to fix Java's stagnation into a mature, multiplatform ecosystem backed by Google and JetBrains. Detekt emerged in 2016 with the first stable release of Kotlin and survived 10 years by adapting to Kotlin changes. It evolved from a simple standalone code smell scanner into a deeply integrated, highly extensible compiler plugin that enforces best practices, security, and architectural rules in both pure Kotlin and Android projects.
Here are the major moves detekt made to adapt:
Google endorsing Kotlin in 2019 → detekt became a Gradle plugin. Android developers were forced to migrate from Java to Kotlin. Before the Gradle plugin, the only way to run detekt on Android was via the command line or manual Gradle task wiring. That was a barrier. A first-class Gradle plugin meant detekt could plug directly into the Android build lifecycle — run automatically on every build, integrate with lint reports, fit the workflow Android developers already had.
Kotlin went multiplatform with KMP → detekt added KMP support. A tool that only worked on Android/JVM would have become irrelevant to half the Kotlin community overnight.
Kotlin introduces Kotlin compiler plugin → Detekt 2.0 brings native compilation with a Kotlin compiler plugin. Detekt now leverages the speed of the compiler for the analysis.
What Makes Detekt Different
Detekt doesn't operate on raw text. It uses the Kotlin compiler's PSI (Program Structure Interface).
In practical terms: it understands your code the same way the compiler does.
That enables deeper analysis:
Detecting complex functions
Identifying large classes doing too much work
Spotting unsafe patterns, not just style violations
This is a key distinction from formatting tools like ktlint.
ktlint → enforces how your code looks
detekt → evaluates how your code behaves and evolves over time
We will use the rule ComplexMethod. ComplexMethod is a rule of the complexity rule set. It measures the cyclomatic complexity of a function or constructor — the number of independent branching paths. It matters because a complex method is hard to maintain in a codebase. A text-based tool can count lines of code but cannot understand the structure of the code like PSI can.
Let's see this snippet:
private fun processItems(items: List<Item>) {
items.forEach { item ->
if (item.isActive) {
if (item.type == Type.A) {
if (item.value > 100) {
compute()
} else if (item.value > 50) {
computeFallback()
}
} else if (item.type == Type.B) {
when (item.priority) {
Priority.HIGH -> handleHigh()
Priority.LOW -> handleLow()
else -> handleDefault()
}
}
} else {
handleInactive(item)
}
}
}
ktlint: ✅ perfectly formatted detekt: ❌ flags ComplexMethod — cyclomatic complexity: 11
In this context, the issue isn't formatting — it's structural complexity that no text-based tool can measure.
How Detekt Works
Detekt cannot analyze raw source code because it is just lines and keywords without structure to analyse.
That's why detekt first parses and converts raw source code into an AST (Abstract Syntax Tree). The AST gives detekt a structured representation of your code as nodes — conditions, classes, methods, variables.
The parsing creates a queryable structure the rules can traverse node by node and measure: count expressions, check names, depths, compare to thresholds.
Each violation found by rules during node traversal becomes an issue. At the end of the process, detekt produces a report with all found issues. Reports are produced in many formats:
HTML for humans
SARIF for CI/code scanning
Markdown for lightweight reporting
Rules and Rule Sets
Rules are grouped into rule sets. Each targets a specific category of issues.
Common rule sets include complexity, style, performance, potential bugs and coroutines.
Complexity is a rule set about complexity that can be found in the code.
ComplexCondition checks if a condition has more than 3 expressions, to ensure simplicity.
if (temperature < 32 && pression < 100 && weight > 200) {
println("this is the right combination")
}
Detekt flags this as ComplexCondition.
if (temperature < 32 && pression < 100) {
println("this is the right combination")
}
Detekt finds no issues.
Style is a rule set about code style.
FunctionNaming checks if a function name complies with the Kotlin style guide.
fun IsTheCarNew() {
}
Detekt flags this as bad naming.
fun isTheCarNew() {
}
Detekt finds no issues.
Performance is a rule set about performance issues detectable from static analysis.
UnnecessaryInitOnArray flags useless initialization of arrays.
val list = IntArray(5) { 0 }
Detekt flags this as unnecessary initialization.
val list = IntArray(5)
Detekt finds no issues.
Potential bugs is a rule set about bugs that can be caught by looking at the code.
EqualsAlwaysReturnsTrueOrFalse checks if an equals method always returns true or false.
val isRunning = true
override fun equals(other: Any?): Boolean {
return isRunning
}
Detekt flags this as EqualsAlwaysReturnsTrueOrFalse.
val isCar = true
var isRunning = true
override fun equals(other: Any?): Boolean {
if (isCar) { isRunning = false }
return isRunning
}
Detekt finds no issues.
Coroutines is a rule set about the correct use of coroutines.
GlobalCoroutineUsage flags coroutines launched with GlobalScope, which bypasses structured concurrency.
GlobalScope.launch {
val record = withContext(Dispatchers.IO) {
fetchLastRecordFromServer()
}
withContext(Dispatchers.Main) {
component.displayLastRecord(record)
}
}
Detekt flags this as GlobalCoroutineUsage.
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
applicationScope.launch {
// your coroutine work here
}
Detekt finds no issues.
Extensibility
Out of the box, detekt rules are useful. But custom rules are where detekt becomes a real engineering tool.
Detekt lets you encode your team's architecture decisions as automated guardrails. This is done by creating custom rules.
Here are 3 examples of architecture decisions you can encode with custom rules:
No ViewModel should exceed a certain complexity threshold
Repository methods must return
Result<T>No direct Retrofit usage outside the data layer
Let's see what those check at the AST level to act as guardrails.
For the ViewModel rule, we go to the class node, check if it extends ViewModel, then check cyclomatic complexity, raise an issue if above threshold. The threshold can be a parameter of the rule.
For the Repository rule, we go to the class node, check if it contains the keyword "repository", iterate the methods and check return type, raise issue if not Result<T>.
For the Retrofit rule, we visit call expression nodes, check if the call references an API service interface, then verify the containing package. If the package is outside the data layer, raise an issue.
For some detail about custom rule creation: extend the Rule class, implement the visitor methods you need, register the custom rule via RuleSetProvider, declare in META-INF, activate in detekt.yml.
This is what turns detekt from a linter into an architectural safety net.
Production-Ready Setup
Let's set up detekt in a real Kotlin or Android project.
Step 1: Add the plugin
plugins {
id("io.gitlab.arturbosch.detekt") version("1.23.8")
}
repositories {
mavenCentral()
}
Quick note about the detekt plugin ID: a new plugin ID was added in version 2 (currently in alpha) — dev.detekt. For this production-grade setup, use the stable ID above.
This gives you tasks depending on your project variants. For Android developers, by default you get detektDebug and detektRelease.
Step 2: Add configuration
Create a detekt.yml file. This file controls:
Enabled or disabled rules
Thresholds, such as max function length
Build failure conditions
Report formats
Start from the default config:
https://github.com/detekt/detekt/blob/main/detekt-core/src/main/resources/default-detekt-config.yml
Then customize minimally. A common mistake is over-configuring too early. Start strict on a few rules, then expand.
Step 3: Run analysis
Detekt gives you multiple tasks depending on what level of analysis you need.
You can run the analysis without type resolution:
./gradlew detekt
You can also run the analysis with type resolution. Type resolution gives detekt one more capability — the ability to know exactly the type and symbols across your codebase. This is particularly useful when spotting bugs or code smells.
Let's see an example. There is a rule called UnnecessarySafeCall that detects when you use the safe call operator unnecessarily.
// getVersionFromDb() returns String, not String?
fun getVersionFromDb(): String = "1.0.0"
// detekt without type resolution: silent
// detekt with type resolution: flags UnnecessarySafeCall
getVersionFromDb()?.let { print("Version: $it") }
To run with type resolution, use detektDebug or detektRelease for Android projects.
Step 3.1: Activate your baseline
With detekt you can ignore legacy debt initially with a baseline file. Here is the command to generate one:
./gradlew detektBaseline
Let's look at the format:
<SmellBaseline>
<ManuallySuppressedIssues>
<ID>CatchRuntimeException:Junk.kt$e: RuntimeException</ID>
</ManuallySuppressedIssues>
<CurrentIssues>
<ID>NestedBlockDepth:Indentation.kt\(Indentation\)override fun procedure(node: ASTNode)</ID>
<ID>TooManyFunctions:LargeClass.kt$dev.detekt.rules.complexity.LargeClass.kt</ID>
<ID>ComplexMethod:DetektExtension.kt\(DetektExtension\)fun convertToArguments(): MutableList<String></ID>
</CurrentIssues>
</SmellBaseline>
The baseline saves all current issues in baseline.xml, letting your team move forward while keeping the codebase clean from that point in time.
The workflow to progressively shrink the list: remove one issue at a time from the baseline, fix it, run detekt again. If the issue no longer appears, it's resolved. If it does, keep fixing.
Quick note on ManuallySuppressedIssues: these are issues you voluntarily suppress with @Suppress because you accept them based on your context. This tag helps detekt avoid false positives.
Step 4: Fail the build when ready
Once the configuration is stable, add the following to your Gradle project build file:
detekt {
buildUponDefaultConfig = true
allRules = false
config.setFrom(files("detekt.yml"))
ignoreFailures = true
}
Set ignoreFailures = true during initial rollout so detekt reports issues without blocking the build. Once your baseline is stable and the team is ready, flip it to false to enforce failures in CI.
What's Coming Next
In Part 2, we'll go deeper into:
Writing custom rules
Integrating detekt with CI/CD properly
Avoiding rule overload
Scaling detekt across large Android codebases