Using Kotlin Compiler Plugins in Android Projects
26 Feb 2026Kotlin compiler plugins are one of the most underused yet powerful tools available to Android developers. Most teams rely on conventions, code reviews, and runtime checks to keep large projects healthy. That works, until it doesnât. Compiler plugins allow you to move entire classes of problems to compile time, where they are cheaper, faster, and impossible to ignore.
This article focuses on what Android developers can practically do with Kotlin compiler plugins to improve long-term maintainability and correctness, with concrete examples and guidance.
What Is a Kotlin Compiler Plugin?
A Kotlin compiler plugin hooks into the compilation pipeline and can:
- Inspect Kotlin syntax trees (AST or FIR)
- Analyze symbols, types, and module boundaries
- Emit compilation errors or warnings
- Generate code
Popular examples include kotlinx.serialization, Room, and Compose. But teams can also write custom plugins tailored to their architecture and domain rules.
Enforcing Architectural Rules at Compile Time
The Problem
Architectural rules are often documented but weakly enforced:
- âViewModels must not reference Android framework classesâ
- âDomain layer must not depend on data or UIâ
- âUse cases must be pure Kotlinâ
These rules are typically enforced via code review or static analysis tools that run after compilation.
The Compiler Plugin Approach
A compiler plugin can analyze symbol usage and fail the build if an architectural rule is violated.
Example: Prevent Android APIs in the Domain Layer
Assume a module structure:
:domain
:data
:ui
We want to ensure :domain never references android.*.
Using the FIR (Frontend IR) API, a plugin can inspect imports and type references:
class DomainAndroidDependencyChecker : FirAnalysisHandlerExtension() {
override fun analysisCompleted(
session: FirSession,
files: List<FirFile>
): Boolean {
files.forEach { file ->
file.collectTypes().forEach { type ->
if (type.classId?.packageFqName?.asString()?.startsWith("android.") == true) {
session.reportError(
file.source,
"Android framework dependency is forbidden in domain module"
)
}
}
}
return false
}
}
Now the rule is:
- Documented
- Automated
- Impossible to bypass
This is superior to lint because it runs during compilation, not as a separate phase.
Preventing Forbidden Dependencies Between Modules
The Problem
Gradle enforces dependencies at the module level, but it doesnât stop you from creating implicit dependencies via shared interfaces, reflection, or generated code.
For example:
- UI module depends on a data implementation class
- Feature A references Feature Bâs internal API
- Circular dependencies sneak in via transitive references
Compiler-Level Enforcement
A compiler plugin can inspect symbol origins and module descriptors to block forbidden dependency graphs.
Example: Feature Isolation
Suppose each feature must only depend on:
:core- itself
You can encode this rule directly:
val allowedDependencies = setOf("core", currentModuleName)
if (referencedSymbol.moduleName !in allowedDependencies) {
error(
"Module '$currentModuleName' is not allowed to depend on '${referencedSymbol.moduleName}'"
)
}
This approach:
- Catches violations even if Gradle allows them
- Detects transitive and indirect dependencies
- Scales well for large multi-module apps
In practice, this replaces brittle documentation with enforceable contracts.
Validating Domain Invariants at Compile Time
The Problem
Domain invariants are often enforced at runtime:
- Money must not mix currencies
- Distances must use consistent units
- Percentages must be between 0 and 100
Runtime validation works, but it is too late. The bug has already shipped.
Strong Types + Compiler Plugins
Value classes help, but compiler plugins allow semantic validation.
Example: Currency-Safe Money Arithmetic
@JvmInline
value class Money(val amount: BigDecimal)
@Currency("EUR")
val price: Money = Money(10.toBigDecimal())
@Currency("USD")
val tax: Money = Money(2.toBigDecimal())
A compiler plugin can read the @Currency annotation and reject invalid operations:
val total = price + tax // â compile-time error
Plugin logic:
if (left.currency != right.currency) {
error("Cannot add Money with different currencies: ${left.currency} and ${right.currency}")
}
This eliminates entire categories of financial bugs and makes illegal states unrepresentable.
Enforcing Correct API Usage
The Problem
APIs are often misused despite documentation:
- Calling suspend functions from non-coroutine contexts
- Using blocking I/O on the main thread
- Forgetting required annotations or preconditions
Compiler Plugin as a Guardrail
Compiler plugins can enforce usage rules that are too contextual for lint.
Example: Repository Access Rules
@Target(AnnotationTarget.FUNCTION)
annotation class IOOnly
@IOOnly
fun fetchUser(): User
Now the plugin ensures:
@IOOnlyfunctions are only called fromDispatchers.IO- or from other
@IOOnlyfunctions
If violated:
Error: fetchUser() must only be called from IO context
This approach is stronger than lint because it understands call graphs and coroutine contexts.
Compile-Time Code Generation with Guarantees
Many Android teams use code generation (KSP, kapt). Compiler plugins go further by:
- Validating inputs before codegen
- Emitting richer diagnostics
- Integrating deeply with type inference
Example: Safer Navigation
Instead of runtime-safe args:
navigate("userDetails?id=$id")
A plugin can:
- Verify destination exists
- Validate argument types
- Generate strongly-typed navigation APIs
navigator.goToUserDetails(userId = UserId(42))
Invalid navigation fails at compile time, not at runtime or QA.
Performance and Developer Experience Considerations
Compiler plugins are powerful but not free.
Best Practices:
- Fail fast: Stop compilation as soon as a violation is found
- Provide actionable errors: Include symbol names and locations
- Avoid global scans: Restrict analysis to relevant modules
- Document rules clearly: The compiler is strict; humans need context
A bad plugin slows builds and frustrates developers. A good one feels like an intelligent teammate.
When a Compiler Plugin Is Worth It
Use a compiler plugin when:
- The rule is non-negotiable
- Violations are costly or subtle
- Runtime checks are too late
- Lint rules are insufficient
Do not use plugins for style or formatting. That belongs elsewhere.
Final Opinion
For medium to large Android projects, Kotlin compiler plugins are a strategic investment. They shift responsibility from humans to tooling, encode architectural intent directly into the build, and dramatically reduce classes of bugs that otherwise survive code review.
Teams that rely only on conventions eventually pay for it. Teams that teach the compiler their rules stop arguing, and start shipping safer code.