Kotlin's pretty concise, expressive. You know, a real joy to work with sometimes. But even with that, languages end up with all these rituals piling on. Boilerplate all over the place. Those repeated patterns that wear thin quick. And yeah, those times you're thinking, man, I wish the compiler took care of this stuff for me. That's basically where a Kotlin Compiler Plugin, or KCP, steps in. It hooks straight into the compilation process. So it reads your code, generates new bits, transforms things here and there, all before bytecode even happens. This affects the IDE too, with things like autocomplete and navigation working better. And it touches the final binaries as well, you know. Kotlin Look at Jetpack Compose, for instance. It turns those @Composable functions into real UI-rendering code, right in the compilation phase. No extra steps or anything. Still, the IDE gets it, understands the whole deal. Oh and, a good KCP makes some features feel like they're just part of the language from the start. Why build one? Some problems aren’t cleanly solvable with libraries or annotation processors. You need to compile time smarts and full IDE integration. Familiar examples: Kotlinx.serialization: Generates reflection-free serializers across JVM/JS/Native. all-open / kotlin-spring: Rewrites annotated classes as open so proxies can subclass them. Kotlinx.rpc: Generates client/server RPC plumbing from annotated interfaces. Kotlinx.serialization: Generates reflection-free serializers across JVM/JS/Native. all-open / kotlin-spring: Rewrites annotated classes as open so proxies can subclass them. Kotlinx.rpc: Generates client/server RPC plumbing from annotated interfaces. These operate under the syntax tree. They change how your code looks, runs, and feels. The Idea: Suspendify The problem So with server-side Kotlin stuff, you know, people often go for this thing where you inject a dispatcher like Dispatchers.IO. Then you wrap those public methods in withContext(dispatcher) { … }. It does the job basically. However, it is noisy and easy to forget too, leading to subtle bugs and inconsistent threading. The solution Annotate a class with @Suspendifyable, and let the compiler generate a wrapper where every public method: becomes a suspend function, and internally calls the original inside withContext(dispatcher). becomes a suspend function, and internally calls the original inside withContext(dispatcher). fun main() { runBlocking { val repository = Repository() val repositorySuspendified: Repository.Suspendified = repository.suspendify(Dispatchers.IO) val entity = repositorySuspendified.findById("id") // suspend method println(entity) } } @Suspendifyable class Repository { fun findById(id: String) = Entity(id) } data class Entity(val id: String) fun main() { runBlocking { val repository = Repository() val repositorySuspendified: Repository.Suspendified = repository.suspendify(Dispatchers.IO) val entity = repositorySuspendified.findById("id") // suspend method println(entity) } } @Suspendifyable class Repository { fun findById(id: String) = Entity(id) } data class Entity(val id: String) Benefits Benefits Benefits here are pretty straightforward. Cleaner source code, you know, no repeated withContext stuff messing things up. Fewer mistakes too, since dispatcher usage stays consistent all the way. IDE synergy works great, with the generated API popping up in autocomplete, thanks to FIR doing its thing. Compile-time guarantees mean everything gets validated before runtime even hits. Typical KCP architecture. KCPs usually come in two logical modules. Plugin-gradle is the one users apply in their build.gradle.kts file. It registers the compiler extension and passes along options. Then there's plugin-kotlin, which is the compiler plugin JAR. That parses CLI args and hooks into FIR or IR to generate or transform code. That's the basic setup. Users add: Users add: plugins { id("io.github.rastiehaiev.suspendify") version "x.y.z" } plugins { id("io.github.rastiehaiev.suspendify") version "x.y.z" } How they connect The Gradle plugin implements KotlinCompilerPluginSupportPlugin to: declare the plugin-kotlin artifact, expose a plugin ID, pass DSL values as CLI args (SubpluginOptions). The Kotlin compiler discovers your plugin via META-INF/services/..., reads CLI, and registers FIR + IR extensions. The Gradle plugin implements KotlinCompilerPluginSupportPlugin to: declare the plugin-kotlin artifact, expose a plugin ID, pass DSL values as CLI args (SubpluginOptions). declare the plugin-kotlin artifact, expose a plugin ID, pass DSL values as CLI args (SubpluginOptions). declare the plugin-kotlin artifact, expose a plugin ID, pass DSL values as CLI args (SubpluginOptions). The Kotlin compiler discovers your plugin via META-INF/services/..., reads CLI, and registers FIR + IR extensions. K2 phases you care about FIR (Frontend IR) parsing, resolution, and declaration generation (so the IDE “sees” synthetic classes/methods). IR (Intermediate Representation) lower-level, platform-agnostic tree where you implement bodies and generate bytecode. FIR (Frontend IR) parsing, resolution, and declaration generation (so the IDE “sees” synthetic classes/methods). IR (Intermediate Representation) lower-level, platform-agnostic tree where you implement bodies and generate bytecode. The Gradle Plugin (DSL + wiring) A minimal, user-friendly DSL: plugins { id("io.github.rastiehaiev.suspendify") version "0.0.9" } suspendify { enabled = true } Implementation sketch: @Suppress("unused") class SuspendifyGradlePlugin : Plugin<Project> { override fun apply(target: Project) { target.extensions.create("suspendify", SuspendifyGradlePluginExtension::class.java) target.plugins.apply(SuspendifyGradleSupportPlugin::class.java) } } open class SuspendifyGradlePluginExtension(var enabled: Boolean = false) KotlinCompilerPluginSupportPlugin translates DSL → CLI: class SuspendifyGradleSupportPlugin : KotlinCompilerPluginSupportPlugin { override fun applyToCompilation(kotlinCompilation: KotlinCompilation<*>) = kotlinCompilation.target.project.provider { val ext = kotlinCompilation.target.project .extensions.findByType(SuspendifyGradlePluginExtension::class.java) ?: SuspendifyGradlePluginExtension() if (ext.enabled) listOf(SubpluginOption("enabled", "true")) else emptyList() } override fun isApplicable(kotlinCompilation: KotlinCompilation<*>) = with(kotlinCompilation.target.project) { plugins.hasPlugin(SuspendifyGradleSupportPlugin::class.java) && (extensions.findByType(SuspendifyGradlePluginExtension::class.java)?.enabled == true) } override fun getCompilerPluginId(): String = with(PluginConfiguration) { "$GROUP_ID.$ARTIFACT_ID_GRADLE" } override fun getPluginArtifact() = with(PluginConfiguration) { SubpluginArtifact(groupId = GROUP_ID, artifactId = ARTIFACT_ID_KOTLIN, version = VERSION) } } plugins { id("io.github.rastiehaiev.suspendify") version "0.0.9" } suspendify { enabled = true } Implementation sketch: @Suppress("unused") class SuspendifyGradlePlugin : Plugin<Project> { override fun apply(target: Project) { target.extensions.create("suspendify", SuspendifyGradlePluginExtension::class.java) target.plugins.apply(SuspendifyGradleSupportPlugin::class.java) } } open class SuspendifyGradlePluginExtension(var enabled: Boolean = false) KotlinCompilerPluginSupportPlugin translates DSL → CLI: class SuspendifyGradleSupportPlugin : KotlinCompilerPluginSupportPlugin { override fun applyToCompilation(kotlinCompilation: KotlinCompilation<*>) = kotlinCompilation.target.project.provider { val ext = kotlinCompilation.target.project .extensions.findByType(SuspendifyGradlePluginExtension::class.java) ?: SuspendifyGradlePluginExtension() if (ext.enabled) listOf(SubpluginOption("enabled", "true")) else emptyList() } override fun isApplicable(kotlinCompilation: KotlinCompilation<*>) = with(kotlinCompilation.target.project) { plugins.hasPlugin(SuspendifyGradleSupportPlugin::class.java) && (extensions.findByType(SuspendifyGradlePluginExtension::class.java)?.enabled == true) } override fun getCompilerPluginId(): String = with(PluginConfiguration) { "$GROUP_ID.$ARTIFACT_ID_GRADLE" } override fun getPluginArtifact() = with(PluginConfiguration) { SubpluginArtifact(groupId = GROUP_ID, artifactId = ARTIFACT_ID_KOTLIN, version = VERSION) } } Note: A template repo can pre-wire Gradle + compiler pieces, service registration, CLI parsing, and publishing defaults. Fork, rename, focus on your logic. Note: A template repo can pre-wire Gradle + compiler pieces, service registration, CLI parsing, and publishing defaults. Fork, rename, focus on your logic. FIR (Frontend IR): Make the IDE See Your API FIR (Frontend IR): Make the IDE See Your API Registering your plugin Registering your plugin Your plugin-kotlin module must register two services under src/main/resources/META-INF/services: org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor — parse CLI options. org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar — register FIR + IR extensions. org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor — parse CLI options. org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor — parse CLI options. org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar — register FIR + IR extensions. org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar — register FIR + IR extensions. @OptIn(ExperimentalCompilerApi::class) class SuspendifyCompilerPlugin : CompilerPluginRegistrar() { override val supportsK2: Boolean = true override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { val keys = configuration.toKeys() if (keys.enabled) { FirExtensionRegistrarAdapter.registerExtension(SuspendifyFirExtensionRegistrar(configuration)) IrGenerationExtension.registerExtension(SuspendifyCompilerIrExtension(configuration)) } } } @OptIn(ExperimentalCompilerApi::class) class SuspendifyCompilerPlugin : CompilerPluginRegistrar() { override val supportsK2: Boolean = true override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { val keys = configuration.toKeys() if (keys.enabled) { FirExtensionRegistrarAdapter.registerExtension(SuspendifyFirExtensionRegistrar(configuration)) IrGenerationExtension.registerExtension(SuspendifyCompilerIrExtension(configuration)) } } } Service files: Service files: org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor io.github.rastiehaiev.SuspendifyCommandLineProcessor io.github.rastiehaiev.SuspendifyCommandLineProcessor org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar io.github.rastiehaiev.SuspendifyCompilerPlugin io.github.rastiehaiev.SuspendifyCompilerPlugin If these are wrong or missing, nothing loads. What FIR does What FIR does FIR is where you add declarations so synthetic classes and methods appear in autocomplete, navigation, and inspections. For each @Suspendifyable class A: add nested A.Suspendified with suspend mirrors of A’s public methods; add factory fun suspendify(dispatcher: CoroutineContext): A.Suspendified add nested A.Suspendified with suspend mirrors of A’s public methods; suspend add factory fun suspendify(dispatcher: CoroutineContext): A.Suspendified Because you inject at FIR, your users see the generated API as if it were written by hand. FIR implementation outline FIR implementation outline Create a FirDeclarationGenerationExtension. Override: FirDeclarationGenerationExtension. getNestedClassifiersNames: list nested classes you’ll synthesize. getCallableNamesForClass: list function/ctor names you’ll synthesize. generateNestedClassLikeDeclaration / generateFunctions / generateConstructors: emit stubs. Naming exmaple getNestedClassifiersNames: list nested classes you’ll synthesize. getNestedClassifiersNames: list nested classes you’ll synthesize. getNestedClassifiersNames getCallableNamesForClass: list function/ctor names you’ll synthesize. getCallableNamesForClass: list function/ctor names you’ll synthesize. getCallableNamesForClass generateNestedClassLikeDeclaration / generateFunctions / generateConstructors: emit stubs. Naming exmaple generateNestedClassLikeDeclaration / generateFunctions / generateConstructors: emit stubs. generateNestedClassLikeDeclaration / generateFunctions / generateConstructors Naming exmaple override fun getNestedClassifiersNames( classSymbol: FirClassSymbol<*>, context: NestedClassGenerationContext, ): Set<Name> = setOf("Suspendified") override fun getCallableNamesForClass( classSymbol: FirClassSymbol<*>, context: MemberGenerationContext ) = when (val key = classSymbol.getDeclarationKey<DeclarationKey>()) { is DeclarationKey.OriginalClass -> setOf("suspendify") is DeclarationKey.SuspendifiedClass -> { val names = key.originalClass.getFunctions().map { it.name }.toSet() names + SpecialNames.INIT // constructor } else -> emptySet() } override fun getNestedClassifiersNames( classSymbol: FirClassSymbol<*>, context: NestedClassGenerationContext, ): Set<Name> = setOf("Suspendified") override fun getCallableNamesForClass( classSymbol: FirClassSymbol<*>, context: MemberGenerationContext ) = when (val key = classSymbol.getDeclarationKey<DeclarationKey>()) { is DeclarationKey.OriginalClass -> setOf("suspendify") is DeclarationKey.SuspendifiedClass -> { val names = key.originalClass.getFunctions().map { it.name }.toSet() names + SpecialNames.INIT // constructor } else -> emptySet() } Generating the suspend methods Generating the suspend methods @OptIn(SymbolInternals::class) private fun DeclarationKey.SuspendifiedClass.createSuspendFunctions( callableId: CallableId, suspendifiedClass: FirClassSymbol<*>, ): List<FirNamedFunctionSymbol> { val originalClassId = originalClass.classId val key = DeclarationKey.SuspendifiedClassFunction( originalClassId = originalClassId, suspendifiedClassId = suspendifiedClass.classId, ) return originalClass.getFunctions() .filter { it.name == callableId.callableName } .filter { !it.isSuspend } .mapNotNull { it.toFunctionSpec(originalClassId) } .map { spec -> createMemberFunction( owner = suspendifiedClass, key = key, name = spec.name, returnType = spec.returnType, ) { status { isSuspend = true } spec.parameters.forEach { p -> valueParameter(p.name, p.type) } } } .map { it.symbol } .toList() } @OptIn(SymbolInternals::class) private fun DeclarationKey.SuspendifiedClass.createSuspendFunctions( callableId: CallableId, suspendifiedClass: FirClassSymbol<*>, ): List<FirNamedFunctionSymbol> { val originalClassId = originalClass.classId val key = DeclarationKey.SuspendifiedClassFunction( originalClassId = originalClassId, suspendifiedClassId = suspendifiedClass.classId, ) return originalClass.getFunctions() .filter { it.name == callableId.callableName } .filter { !it.isSuspend } .mapNotNull { it.toFunctionSpec(originalClassId) } .map { spec -> createMemberFunction( owner = suspendifiedClass, key = key, name = spec.name, returnType = spec.returnType, ) { status { isSuspend = true } spec.parameters.forEach { p -> valueParameter(p.name, p.type) } } } .map { it.symbol } .toList() } Why this matters: FIR stubs are what make the IDE see your API. IR will fill in bodies later; without FIR the editor would be blind. Why this matters: FIR stubs are what make the IDE see your API. IR will fill in bodies later; without FIR the editor would be blind.