Architecture
The plugin is one machine run in two directions. Each way, an intermediate representation (IR) sits in the middle: a reader fills it from one language's metadata, and a renderer emits the other language's source. The forward and reverse pipelines are deliberate mirrors of each other.
This page covers how the bridge is built, then traces one real type through every stage in each direction, using the plugin's own generated output from test-library.
Build pipeline
Gradle plugin compiles Kotlin/Native, runs KSP to generate C# bindings (via the CIR model) and Kotlin bridge wrappers (via KotlinPoet,
CNameExports.kt), links shared libraries, and packages everything. It also addsnuget-runtime, a small Kotlin/Native library carrying the fixednuget_*ABI, as anapidependency andexport()s it into everySharedLibrary, so the shared library carries both the generated per-declaration code and the runtime's fixed exports.NuGet package ships native libs + pre-generated
Interop.cs. No consumer-side tooling required.Consumer just includes the package — bindings are ready at build time.
The project publishes three Maven Central artifacts: nuget-processor (the KSP reader/renderer), nuget-plugin (the Gradle plugin), and nuget-runtime (the fixed ABI, ADR-127). nuget-runtime is the project's first artifact carrying kotlin-tooling-metadata.json, which makes it indexable on klibs.io; the other two are plain JVM jars.
Two mirrored IRs
Feature logic lives only in the IR plus its reader and renderer. The task plumbing around them is stable in both directions, so a newly mapped language feature is a change in one place, not across the pipeline.
Role | Forward (CIR) | Reverse (RIR) |
|---|---|---|
Middle IR |
|
|
Reader that fills it | KSP, over Kotlin symbols |
|
Handoff | in-memory, same JVM process |
|
Renderer that emits source |
| Kotlin-stub + C#-shim codegen |
Runtime direction | C# calls Kotlin (P/Invoke) | Kotlin calls C# (function pointers) |
Decision record |
Forward slice: a data class into C#
Two source lines of Kotlin. Follow the native prefix toy_ as it threads through every stage: it is minted in the CIR, becomes the [DllImport] entry point on the C# side, and the matching @CName export on the Kotlin side. Same name, both ends of the ABI.
1. Kotlin source
The entire input. Everything downstream is generated.
2. KSP fills the CIR (the reader)
KSP resolves the declaration and its types, then builds a CirClass in memory. This is where the toy_ native prefix and the per-member entry points are decided, and where data-class machinery (Copy, Equals, ToString) is materialized. Because the CIR lives in the same JVM process, it is never serialized; this is a faithful rendering of the in-memory model:
3. CirRenderer emits the C# surface
The consumer-facing API. The Kotlin object lives behind an opaque IntPtr _handle; every member is a [DllImport] into the native library, wrapped in idiomatic C# with UTF-8 string marshalling and error propagation. The entry points match the CIR exactly.
4. The Kotlin export side
The same renderer emits @CName top-level functions that Kotlin/Native compiles into C exports. Each catches Throwable and writes it back through errorOut, which is the out IntPtr error the C# side reads. Handles cross through NugetHandles.retain/release, a StableRef counter.
NugetHandles and buildError are not generated into this file: they, and the rest of the roughly 500 declaration-independent lines every project used to regenerate (the scalar wrap/unwrap exports, collections, callbacks, coroutine plumbing), live in a small nuget-runtime Kotlin/Native library that the plugin adds as an api dependency and export()s alongside your own module. CNameExports.kt only carries the per-declaration functions and imports the runtime package:
At runtime, C# new Toy(...) calls P/Invoke toy_create, which reaches Kotlin export_toy_create and returns a handle from NugetHandles.retain. See Publishing Kotlin to C# for the full forward pipeline, and ADR-127 for why the fixed ABI moved into its own library.
The reverse bridge (below) builds its own thrown-exception envelope through the same buildError, rather than a second, structurally identical error class: internal expect fun nugetKotlinError(t: Throwable) in the generated nativeMain file, with a per-target actual that calls StableRef.create(buildError(t)).asCPointer() where the runtime is visible. See ADR-130.
Reverse slice: a C# class into Kotlin
The mirror image. The plugin resolves a NuGet dependency, reads its compiled metadata, and emits Kotlin you can call. There is no source to author on the Kotlin side. Follow one member, Apply, from the assembly's managedSignature to a signature-derived bridge slot that both generated sides agree on.
1. C# source, in the package
Compiled into TestDependency.dll and shipped to a feed. The plugin only ever sees the assembly, never this source.
2. NugetMetadataReader fills the RIR (the reader)
A .NET console tool reads ECMA-335 metadata and emits reverse-ir.json, the one artifact that crosses the JVM ↔ .NET process boundary. Each member carries its managedSignature, which is later hashed into a stable bridge identity so the two generated sides cannot drift apart.
3. The renderer emits the Kotlin wrapper
Idiomatic Kotlin: instance members on the class, statics in a companion object, get/set for settable properties, and AutoCloseable plus a Cleaner over the underlying GCHandle. Every call reads a function pointer from TemplateBindings, the slot keyed by that hashed signature.
4. The native binding table and register export
The wrapper's function pointers start null. A @CName export lets C# hand them over at startup. Before storing anything, it checks the contract (slotCount plus contractHash): a stale shim with the wrong shape is rejected up front instead of corrupting the call. See ADR-054 and Registration diagnostics.
5. The C# registration shim
The mirror of the binding table. A [ModuleInitializer] fires at process start and calls the Kotlin register export, passing [UnmanagedCallersOnly] thunks as raw function pointers. Each thunk unwraps the GCHandle, calls the real C# member, and marshals the result back.
At runtime, Kotlin template.apply(name) invokes the stored function pointer, which lands in the C# Apply_Thunk, which calls Template.Apply and marshals the string back. See Consuming C# in Kotlin for the full reverse pipeline.
Runtime call flow
The Kotlin/Native library always runs inside a .NET host process, so neither direction needs a runtime host. Forward calls go out over P/Invoke; reverse calls go out over function pointers registered once, at startup.
Forward, C# calls Kotlin. A
[DllImport]bound to a generated@CNameexport. Synchronous and direct. Errors ride back through anout IntPtr errorthe export fills from any thrownThrowable. A forward call can also carry a delegate the other way: C# pins aFunc<>/Action<>withGCHandleand hands it over as a function pointer, which Kotlinreinterprets toCPointer<CFunction<…>>and invokes as a lambda (insidefilter/map/…), calling back into C#. See ADR-036.Reverse, Kotlin calls C#. No P/Invoke into C#. At startup, a
[ModuleInitializer]hands Kotlin every thunk pointer (contract-checked), which Kotlin stores in a bindings table. Each later call invokes the stored pointer. A stale shim is caught at registration: a mismatchedslotCountorcontractHashrefuses to store any pointer.
The Gradle tasks that build the artifacts
Two task chains, one per direction. See Gradle tasks for the full reference.
Forward: publish Kotlin → C#, producing MyLib.1.0.0.nupkg:
Task | Does | Produces |
|---|---|---|
compile + link | Kotlin/Native shared libs, one per target RID |
|
KSP → CIR | reader + renderer emit the C# and Kotlin bridge source |
|
| assembles |
|
Reverse: consume C# → Kotlin, aggregated behind the nugetImport IDE-sync task:
Task | Does | Produces |
|---|---|---|
| writes a synthetic |
|
|
|
|
| runs |
|
| Kotlin stubs + native binding tables from the RIR |
|
| C# registration shims with thunks + |
|