In the first part, we explored how APIs are generated, the role of schema definitions, and how these connect to the broader developer workflow. This foundation helped us understand not just what is produced but why it matters for consistency and scalability.
In this part, we’ll dive deeper, first into how the IDE becomes aware of these generated APIs, and then into IR (Intermediate Representation) to see how definitions translate into real implementations.
IR (Intermediate Representation): Building Bodies That Work
Why IR?
IR is the universal, platform-neutral tree after FIR. This is where you implement bodies for your FIR stubs constructing calls, lambdas, receivers, and control flow. It’s powerful and low-level: symbol/receiver mismatches can break compilation.
The target we want
Starting source:
class Repository {
fun findById(id: String) = Entity(id)
}
What your plugin should synthesize:
fun Repository.suspendify(dispatcher: CoroutineDispatcher): Suspendified =
Suspendified(this, dispatcher)
inner class Suspendified(
private val delegate: Repository,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun findById(id: String) = withContext(dispatcher) {
delegate.findById(id)
}
}
While developing, an IR dump helper is invaluable, it dumps IR trees for annotated types so you can mirror the structure precisely.
IR extension scaffolding
class SuspendifyCompilerIrExtension(configuration: CompilerConfiguration) : IrGenerationExtension {
private val logger = configuration.getLogger()
override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
moduleFragment.transformChildrenVoid(SuspendifyIrTransformer(pluginContext, logger))
}
}
Implement IrElementTransformerVoid and override:
-
visitConstructor build the Suspendified primary constructor + fields/getters
-
visitFunction implement:
- the factory suspendify(...),
- each suspend wrapper using withContext(dispatcher).
-
visitValueParameter — provide default value for dispatcher (Dispatchers.IO) so suspendify() can be parameterless.\
Constructor shape (from IR dump)
CONSTRUCTOR (...) [primary] $outer: VALUE_PARAMETER <this>: Repository VALUE_PARAMETER delegate: Repository VALUE_PARAMETER dispatcher: CoroutineDispatcher EXPRESSION_BODY CALL Dispatchers.<get-IO>() BLOCK_BODY DELEGATING_CONSTRUCTOR_CALL Any.<init>() INSTANCE_INITIALIZER_CALL Suspendified
Implementation sketch
override fun visitConstructor(declaration: IrConstructor): IrStatement {
val pluginKey = declaration.origin.getPluginKey<DeclarationKey.SuspendifiedClassConstructor>()
?: return super.visitConstructor(declaration)
declaration.body = irBuilder(declaration.symbol).irBlockBody {
+irDelegatingConstructorCall(context.irBuiltIns.anyClass.owner.constructors.single())
+IrInstanceInitializerCallImpl(
startOffset = startOffset,
endOffset = endOffset,
classSymbol = pluginKey.suspendifiedClassId.toClassSymbol(),
type = context.irBuiltIns.unitType,
)
}
val nested = declaration.parentAsClass
val props = listOf(
Meta.SuspendifiedClass.dispatcherParameter.name to Meta.SuspendifiedClass.dispatcherParameter.type.toIrType(),
Meta.SuspendifiedClass.delegateParameterName to pluginKey.originalClassId.toIrType()
)
props.forEach { (name, type) ->
val vp = declaration.valueParameters.first { it.name == name }
val prop = nested.addProperty {
this.name = name
visibility = DescriptorVisibilities.PRIVATE
}
prop.addBackingField { this.type = type }.apply {
initializer = factory.createExpressionBody(
IrGetValueImpl(
UNDEFINED_OFFSET, UNDEFINED_OFFSET,
vp.type, vp.symbol,
origin = IrStatementOrigin.INITIALIZE_PROPERTY_FROM_PARAMETER
)
)
}
prop.addDefaultGetter(nested, pluginContext.irBuiltIns) {
visibility = DescriptorVisibilities.PRIVATE
}
}
return super.visitConstructor(declaration)
}
Wrapper method logic
Each wrapper should look like:
return withContext(dispatcher) {
delegate.original(args)
}
Steps you’ll encode:
-
Load dispatcher via generated getter on the Suspendified receiver.
-
Build a FUN_EXPR (lambda) capturing parameters.
-
Inside lambda, call original on delegate.
-
Wire type arguments, receivers, and value parameters correctly.
The complete implementation is longer (symbol lookups, factories, scopes). Keep your IR builder helpers tidy; small mistakes surface as cryptic errors.
Publishing, Putting It All Together & Conclusions
Publishing plan
You’ll publish three artifacts:
- plugin-gradle: via java-gradle-plugin so users can apply id("...")
- plugin-kotlin: the compiler plugin JAR, via net.thebugmc.gradle.sonatype-central-portal-publisher
- Lib: a tiny module exposing @Suspendifyable (also via the Sonatype publisher)
Also configure maven-publish for publishToMavenLocal to iterate quickly.
One-command release
Create a root task (e.g., in buildSrc) called publishPluginsAndLibs that:
- scans subprojects for available publish tasks (publishPlugins, publish, publishToMavenLocal);
runs the appropriate ones.
Add a./publish.sh wrapper for convenience.Project Layout
A clean, testable structure:
lib/ — @Suspendifyableannotation (no compiler deps).
plugin-kotlin/— FIR + IR logic (compiler JAR).
plugin-gradle/ — user DSL (suspendify { enabled = true}) and wiring to the compiler plugin.
example/ — sample consumer to validate IDE visibility + runtime behavior.What happens at build table
Consumer build script:
plugins { id("io.github.rastiehaiev.suspendify") version "x.y.z" } dependencies { implementation("io.github.rastiehaiev:suspendify-core:x.y.z") } suspendify { enabled = true // true by default }
Behind the scenes:
-
Gradle plugin registers the compiler artifact and passes a CLI flag like
io.github.rastiehaiev.suspendify.enabled=true. -
The Kotlin compiler discovers your registrar via META-INF/services and activates FIR + IR extensions.
-
FIR phase finds @Suspendifyable classes and generates declarations:
- nested A.Suspendified,
- factory suspendify(...),
- suspend mirrors of public methods.
-
IR phase implements those stubs:
- constructor + backing fields/getters,
- suspendify(...) factory body,
- each suspend wrapper using withContext(dispatcher) { original(...) }
Because FIR injects declarations early, the synthetic API appears in the IDE before IR runs.
Practical starting checklist
- Start tiny: generate one method or property.
- Learn FIR to add declarations the IDE will recognize.
- Implement bodies in IR; rigorously mirror shapes using an IR dump helper.
- Add Gradle DSL → CLI wiring (SubpluginOptions).
- Iterate in an example/ project; publish to mavenLocal often.
- When stable, ship to Central + Gradle Plugin Portal.
Conclusion
Over the course of these four parts, we’ve walked through the why, what, and how of Kotlin compiler plugins, using “Suspendify” as a practical case study. Let’s bring the threads together.
The Bigger Picture: Why Compiler Plugins Matter
Kotlin is already an expressive and developer-friendly language, but as with any language, real-world projects accumulate boilerplate and recurring patterns. Compiler plugins let you extend the language itself to meet the needs of your team, project, or framework. Instead of forcing developers to follow conventions manually, a compiler plugin enforces, generates, and guarantees them automatically all while integrating seamlessly with the IDE.
This is a level of power beyond libraries or annotation processors: it’s about teaching the compiler new tricks, effectively evolving Kotlin into a version that fits your workflow.
The concepts and the representation
-
**Concepts & Theory (Part 1): \ We already covered the basics on compiler plugins, you know, what they are and why they matter. They help get rid of stuff like runtime libraries or processors in different ways. That Suspendify idea showed how you can wipe out common coroutine boilerplate right at compile time. It makes the code cleaner and safer, even more maintainable in the long run. We also dug into the plugin architecture, the way it splits between Gradle and Kotlin modules. And then there are those compilation phases, FIR and IR, which give natural spots to hook in our logic.
-
**Frontend IR (Part 1): \ FIR really drives home how important developer experience is. Without those FIR stubs, the IDE would be totally blind to any generated APIs. I mean, by injecting declarations during the frontend phase, we make sure autocompletion, navigation, and static analysis just work without any hassle. This is the thing that makes compiler plugins feel natural. Developers don't even think of them as external tools. They see them as part of the language itself.
-
Intermediate Representation (Part 2):
This is where the real heavy lifting goes down. We move from declarations over to actual implementations here. We're wiring in the logic that makes Suspendify actually useful, like wrapping methods withContext(dispatcher). The phase gets intricate fast. Handling symbols, parameters, scopes, and receivers correctly, that takes precision. But it's also universal, working across all Kotlin targets, JVM, JS, Native, you name it. -
Publishing & Integration (Part 2):
Publishing and Integration, part 2. Finally, we got into the real-world stuff. Like packaging the plugin properly, exposing a clean Gradle DSL. And publishing artifacts so others can grab your plugin with just a single line in their build script. This step is crucial. A plugin only makes an impact if it's usable and easy for the community to discover.
The Benefits of This Approach
- Cleaner Code: Developers focus on business logic, while the compiler eliminates repetitive scaffolding.
- Consistency & Safety: Patterns like coroutine dispatching or serialization are enforced by the compiler, leaving less room for human error.
- IDE Integration: Generated code is visible and navigable in the IDE, preserving a seamless developer experience.
- Performance: All transformations happen at compile time, with zero runtime reflection overhead.
- Extensibility: Once you understand FIR and IR, you can apply the same principles to countless other problems from generating API clients to enforcing architectural rules.
Lessons for Aspiring Plugin Authors
If you’re new to compiler plugins, the process may feel daunting at first especially when working with IR. But the journey pays off quickly. Some practical advice:
- Start small. Generate one property, one method, or one stub class.
- Leverage FIR first. Ensure your generated API is visible in the IDE before you worry about IR bodies.
- Use IR dump tools. They help you inspect and mirror Kotlin’s internal representation precisely.
- Iterate locally. Publish to mavenLocal, test in a sample project, then refine.
- Think about ergonomics. A plugin is not just code transformation; it’s also about making life easier for end users. A simple Gradle DSL can be the difference between adoption and abandonment.
Looking Ahead
Kotlin's moving pretty fast these days. The K2 compiler, it opens up all these new possibilities for plugins, you know. As the ecosystem keeps maturing, we'll probably see more plugins coming from the community. Those could really push what the language can handle. Maybe even turn patterns like Suspendify into something everyone just does as standard practice.
Compiler plugins, they're basically the next big step for getting more done with Kotlin. They allow us to tweak the language so it fits right into our specific domain. It becomes this tool that matches our architecture and how we code. All while keeping things safe, consistent, and performing well.
Final Thoughts
The Suspendify example really shows off how you can tackle that annoying repetitive stuff with coroutines, all sorted out nicely at compile time. But hey, it's not just about that one thing. The big point here is compiler plugins let you shape Kotlin exactly how you want it, instead of twisting your own code around to match what the language expects.
You know, whether you're putting together some tools for your team or frameworks that the whole community can use, or even just messing around with experiments to grow your skills personally, getting good at these plugins is a huge step up. Basically, it's not only about coding. It's about creating the language that handles the coding for you.