Using Kotlin Compiler Plugins in Android Projects

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

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:

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:

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:

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:

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:

In practice, this replaces brittle documentation with enforceable contracts.


Validating Domain Invariants at Compile Time

The Problem

Domain invariants are often enforced at runtime:

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:

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:

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:

Example: Safer Navigation

Instead of runtime-safe args:

navigate("userDetails?id=$id")

A plugin can:

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:

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:

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.