Kotlin/CLR — a .NET backend for Kotlin

1. The answer first

Suppose you want Kotlin to consume .NET libraries, implement .NET interfaces, subclass .NET classes, and expose Kotlin APIs to C# with about the friction of Kotlin and Java. Do not put C# on the JVM. Write Kotlin/CLR: a Kotlin compiler backend that emits CIL and ECMA-335 metadata. The CLR was built for this. One type system, one GC, one object model, many languages.

Five things to take from what follows.

  1. Almost all the value is in the importer. Every use case that justifies this project looks the same: Kotlin calls a large .NET host, and the host calls a handful of Kotlin entry points. So spend the effort asymmetrically. Fidelity when reading .dll metadata and applying .NET attributes matters enormously. Polish on the export side matters much less.
  2. Build the importer the way Kotlin already reads Java. Resolve assembly metadata lazily in the frontend, then run a second pass that applies nullability. A clrinterop tool that pre-generates Kotlin stubs would repeat every problem Kotlin/Native's C interop has: stale bindings, enormous generated files, and an IDE resolving against a snapshot.
  3. Do not start with IKVM. Start by hand-writing the CIL for twenty Kotlin declaration shapes and consuming them from C#. That corpus is the ABI specification, and it should exist before the first lowering. IKVM earns its place later, as a compatibility lane for JVM-only libraries.
  4. Budget for coroutines and serialisation up front. A target without them is a compiler demo. The cost is lower than it looks: these libraries are mostly commonMain, and the CLR's base library removes the need for several others outright. One decision inside coroutines is load-bearing — whether a suspension carries .NET's ExecutionContext. It must. See §14.
  5. Reaktor gets two different .NET stories, years apart. The FFI switchboard C ABI and rgp/1 give C# a bridged seat, and they need no compiler work. Kotlin/CLR makes C# first-party: same heap, same GC, a C# class that is a Reaktor node. That is where the reach comes from, and it is the only option for hosts that make you subclass MonoBehaviour. See §17.

None of this is built. It is a design and a decision brief.

2. Why the CLR

Consider the alternatives.

C# on the JVM means writing a C# frontend and then discovering that the interesting C# libraries do not port. They bind to the BCL, to Span<T>, to P/Invoke, to Windows. You would inherit an ecosystem's API surface without its runtime, which is the mistake every "language X on runtime Y" reimplementation makes.

A bridge — gRPC, a socket, JNI over P/Invoke, two processes — gives you neither shared object identity nor a shared GC. That is the right answer when the two halves really are separate systems. It is the wrong answer when you want a Kotlin class to be a MonoBehaviour.

The CLR is the only managed runtime besides the JVM whose specification was written for multiple source languages from the beginning. ECMA-335 defines a Common Type System, a Common Language Specification for cross-language API design, self-describing metadata, and a bytecode. C#, F# and VB are peers on it. That property is the whole reason to go here.

3. Where this sits in the compiler

Kotlin already has the right shape. The frontend (K2/FIR) and the common backend IR are shared; JVM, JS, Wasm and Native are separate backends over that IR.

                     Kotlin source
                          │
                         K2 / FIR ───────── ClrSymbolProvider (reads .dll)
                          │
                    Kotlin common IR
             ┌────────────┼─────────────┬─────────────┐
             │            │             │             │
           JVM           Wasm         Native         CLR          <- new
             │            │             │             │
       JVM bytecode      wasm       machine code   CIL + ECMA-335
                                                    metadata
                                                        │
                                                  .NET assembly
                                                      (.dll)
                                                        │
                                        ┌───────────────┴───────────────┐
                                      Kotlin                           C#

Structurally:

compiler/
    ir/
        backend.jvm/
        backend.js/
        backend.wasm/
        backend.clr/          <- new
    fir/
        fir-java/
        fir-clr/              <- new: metadata reading + type enhancement
libraries/
    stdlib/
        common/
        jvm/
        clr/                  <- new

The result is a platform target. You declare it like any other:

kotlin {
    jvm()
    clr()
}

3.1 The importer

Kotlin/JVM does not generate Kotlin stubs for Java. The frontend reads .class files through a JavaSymbolProvider, resolves Java declarations lazily as they are referenced, and then runs an enhancement pass that applies nullability annotations to the raw Java types. Copy that design. fir-clr needs three pieces, each with an existing counterpart:

PieceJava counterpartJob
ClrClassFinderJavaClassFinderresolve an assembly-qualified name to a metadata handle
ClrSymbolProviderJavaSymbolProviderbuild FIR symbols lazily from ECMA-335 tables
ClrTypeEnhancementJava type enhancementapply [Nullable] / [NullableContext], generic variance, mapped types

Reading ECMA-335 tables is a boring, well-specified problem. The BCL alone holds over a hundred thousand types, so laziness here is not an optimisation. It is the only way the frontend stays usable.

The alternative — a clrinterop step that generates Kotlin declarations ahead of time — is how Kotlin/Native handles C, and its problems are familiar to anyone who has used it. Bindings go stale against the library. The generated surface for a large header is enormous. The IDE resolves against a snapshot. Every version bump becomes a regeneration step in the build.

Do keep one small tool: a clr-metadata-dump that prints what the importer believes a type looks like. It costs a day and it is the debugging surface for every "why can't Kotlin see this overload" question you will ever get.

3.2 The emitter

Writing PE files and ECMA-335 metadata from the JVM means reimplementing MetadataBuilder, BlobBuilder, signature encoding, portable PDB emission and strong-name signing in Kotlin. Thousands of lines of format plumbing with no intellectual content, and a long tail of "the runtime rejects this assembly" bugs.

Split the backend at a serialised boundary instead:

Kotlin IR
   │  backend.clr lowerings           (JVM process, in-tree)
   ▼
CLR module description                (stable, versioned, serialised)
   │
   ▼
kotlin-clr-emitter                    (a .NET tool)
   │  System.Reflection.Metadata
   ▼
assembly.dll + assembly.pdb

Three things this buys beyond not writing a PE writer.

A test seam. The module description is inspectable, diffable and golden-file-able without running the CLR. Most codegen regressions become text diffs.

Roslyn. The emitter is a .NET process, so it can host Roslyn. That is the escape hatch for the source-generator problem in §9.6, which is otherwise a hard blocker on Orleans, Godot and modern ASP.NET.

Debug info. Portable PDB writing lives in the same library you are already using.

The cost is an extra process in the build and a protocol to keep in sync. Worth it.

4. The ABI: a single member set

Emit one member set, shaped for the CLR, and carry Kotlin-only information out of band.

The tempting alternative is two ABIs: a Kotlin-facing one carrying $default bridges, continuations and mangled inline classes, plus a clean CLS-style one for C#. That doubles the assembly surface and fills C# IntelliSense and F# completion with compiler artifacts, which is precisely the complaint people have about consuming Kotlin from Java.

Kotlin-only informationWhere it goes
suspend, internal visibility, sealed hierarchy, variance, expect/actual, inline, value-class representationa [KotlinMetadata] attribute plus a metadata blob resource
compiler-generated helpers that must exist but should not be seen[CompilerGenerated] + [EditorBrowsable(Never)], and internal with [InternalsVisibleTo] where possible

The CLR then dissolves most of the reasons the split existed on the JVM.

Default arguments live in metadata. The CLR stores parameter defaults in the Constant table, surfaced as [Optional] / [DefaultParameterValue]. So fun foo(x: Int = 10, y: String = "hi") emits as one method whose parameters carry their defaults, and C# writes Foo(y: "no"). No $default synthetic. No overload explosion. This is strictly better than Kotlin/JVM.

internal lines up. Kotlin's internal is module-scoped, CLR internal is assembly-scoped, and a Kotlin module compiles to an assembly. So internal maps to internal, and the JVM's public-plus-name-mangling hack disappears. Test source sets and friend modules use [InternalsVisibleTo], which is what .NET does anyway.

Extension functions are real extension methods. A static method carrying [Extension], with [Extension] on its enclosing static class, is exactly what C# emits for its own extension methods. Kotlin extensions become C#-callable extension methods with no wrapper.

Interfaces can carry bodies. .NET Core 3.0 and later support default interface methods, so Kotlin interfaces with implementations need no DefaultImpls companion class. One of the ugliest parts of the Kotlin/JVM ABI never has to be built.

Interface dispatch is slot-based with explicit overrides. That dissolves the signature-clash problems Kotlin hits on the JVM, MutableList.remove(Int) being the famous one.

What remains genuinely Kotlin-only is small: continuation plumbing for suspend (§8), inline-class representation where a struct will not do, and the metadata blob. Small enough to hide properly.

5. Primitives, strings and numbers

Map onto the CLR's own representations.

KotlinCLRNote
AnySystem.Object
StringSystem.Stringboth UTF-16, both immutable, both sealed
BooleanSystem.Boolean
ByteSystem.SBytesigned, as in Kotlin
UByteSystem.Byte
Short / UShortSystem.Int16 / UInt16
Int / UIntSystem.Int32 / UInt32
Long / ULongSystem.Int64 / UInt64
Float / DoubleSystem.Single / Double
CharSystem.CharUTF-16 code unit in both
ThrowableSystem.Exceptionsee §7.4
Unitvoid in return position, a Kotlin.Unit struct elsewhereTask<void> is not a type
Nothingvoid plus [DoesNotReturn], and an uninstantiable Kotlin.Nothing for type arguments

So fun foo(x: Int): String is System.String foo(System.Int32 x). Never KotlinString foo(KotlinInt x). This is the most important rule in the document: the moment a boxed Kotlin representation appears in a public signature, the project has quietly become an FFI layer.

5.1 ByteArray is the mapping that bites

Byte is signed in Kotlin and System.Byte is unsigned, which is why the table says Byte → sbyte. Follow that through naively and ByteArray becomes sbyte[]. Every stream, socket, file, hash, crypto and serialisation API in .NET takes byte[], so Kotlin could not call Stream.Read without a copy.

Map ByteArray to byte[] and treat element signedness as a view. The storage is identical. CIL has both signed and unsigned one-byte element loads over the same array, ldelem.i1 and ldelem.u1, and the CLR already treats byte[] and sbyte[] as assignment-compatible at runtime under same-size primitive array compatibility. Kotlin's signed element semantics then cost nothing and the whole BCL is directly reachable. Pin it with a conformance test, since it is runtime behaviour rather than a language guarantee. A copy on every IO call is not an option.

5.2 Arithmetic

Three traps.

Overflow. Kotlin arithmetic wraps. CIL has both add and add.ovf, and a C# project can be built with CheckForOverflowUnderflow. Always emit the unchecked forms, and make that immune to any project-level setting. A Kotlin Int must wrap inside a checked assembly.

Shifts. Kotlin masks the shift count, & 31 for Int and & 63 for Long, matching the JVM. ECMA-335 leaves shl and shr unspecified when the count reaches the operand width. x64 happens to mask. ARM does not agree. Emit an explicit mask. This is the kind of bug that passes every test on a laptop and fails on a phone.

Rounding. Math.Round is half-to-even. Kotlin's roundToInt and roundToLong are half-up, while kotlin.math.round is half-to-even. The two Kotlin rounding families therefore map to different .NET operations, and one wrong intrinsic silently changes financial results.

5.3 Strings

The cleanest mapping in the document, with two sharp edges.

Comparison. .NET's String.CompareTo and String.Compare are culture-sensitive by default. Kotlin's String.compareTo is ordinal over UTF-16 code units, so it maps to String.CompareOrdinal. Get this wrong and your sort order changes with the machine's locale — a classic .NET porting bug, and one a JVM-shaped test suite will never catch.

Hash codes. String.GetHashCode() is randomised per process on .NET Core. Kotlin code that assumes JVM-stable hashCode() behaves differently: persisted bucket layouts, hash-derived shard keys, golden tests over HashMap iteration order. Document it. Do not fix it by shipping a Kotlin-specific string hash, because then Kotlin and BCL dictionaries disagree about the same string.

One more: Char.isLetter() and friends resolve against whatever Unicode version the runtime ships, which is not the JVM's. Character classification will never be bit-identical across targets.

6. Nullability

Kotlin's String? and C#'s string? look like the same feature. They are not. The CLR has no nullable reference type. C# encodes string? as [Nullable] and [NullableContext] attributes carrying a flattened byte array over the type tree, and the runtime enforces none of it. A C# library annotated string will hand you null.

So this is annotations plus runtime checks.

Emitting. fun find(id: Int): User? emits User with the metadata that makes C# see User?. Three details:

  • NullableAttribute and NullableContextAttribute are compiler-embedded types, not shared BCL types. Kotlin/CLR emits its own copies into every assembly, exactly as Roslyn does.
  • Kotlin has shapes the encoding cannot express: T & Any, and the difference between an unconstrained T and a T? whose bound is already nullable. These go in the metadata blob and degrade to "oblivious" for C#.
  • Keep the parameter checks. Kotlin/JVM inserts Intrinsics.checkNotNullParameter at public entry points precisely because Java annotations are unenforced, and the same reasoning applies here with the same force. Whether the policy is every public entry point, or configurable, or off in release, is a real decision; the default should be on. An unenforced annotation from a C# caller is the likeliest way a null reaches Kotlin code whose type says it cannot.

Importing. string Foo() becomes fun foo(): String, string? Bar() becomes fun bar(): String?, and an assembly carrying no nullability metadata at all — plenty of NuGet packages, and most of the pre-2019 world — must produce platform types, String!, with the same warning-not-error behaviour Kotlin gives unannotated Java. The Kotlin/Java model transfers exactly here, and it should transfer deliberately. An importer that guesses non-null for unannotated assemblies reproduces the runtime failures that made platform types necessary in the first place.

7. The object model, decision by decision

7.1 Generics, variance and constraints

Reified generics are a real improvement over erasure. class Box<T>(val item: T) becomes a genuine Box<T> in metadata, value-type instantiations get specialised, and Dictionary<Guid, User> is the type it claims to be.

Three qualifications.

Declaration-site variance does not survive on classes. CLR generic variance, +T and -T, is legal only on interfaces and delegates. class Box<out T> cannot be covariant in CIL. Kotlin's own List<out E> is fine because it is an interface, but a user's covariant class loses its covariance when viewed from C#. So: keep declaration-site variance on interfaces, erase it to invariant on classes, keep use-site projections compiler-only, and record the real variance in the metadata blob so Kotlin→CLR→Kotlin round-trips losslessly. Then document that C# sees less than Kotlin does. Preferring interface-based collection mappings removes most of the practical sting.

Constraints run the other way. Kotlin has no where T : struct, where T : class or where T : new(). Consuming a generic method with those constraints is fine; the compiler just has to satisfy them. Implementing a .NET generic interface that declares them is not, and neither is instantiating a type parameter the CLR believes is new()-constrained. The importer needs marker types or intrinsics for the constraint kinds Kotlin cannot state, and it must refuse clearly instead of dropping a constraint and emitting an assembly the runtime rejects at type-load.

Specialisation costs something. Value-type instantiations mean more code, and generic virtual methods over value types are a known pain point for NativeAOT and IL2CPP. That belongs to §10, not the MVP.

One thing must not change: Kotlin source semantics. is List<String> stays an error even though the CLR could answer it, and inline fun <reified T> keeps working identically. A target that quietly enables source constructs the other targets reject is not a Kotlin target.

7.2 Value classes and structs

Mapping value class UserId(val value: Long) to readonly struct UserId is the attractive case, and it is unsound in two ways.

Every CLR struct has a zero-initialised default you cannot suppress. default(UserId) and new UserId[10] produce instances whose init block never ran. A value class with require(value > 0) in init therefore has invalid instances reachable from C#, and from Kotlin code that allocates an array of them.

UserId? is Nullable<UserId>, a different type with different identity and different generic instantiation. Box<UserId> and Box<UserId?> are unrelated types on the CLR, where the JVM's boxed representation papers over it.

So:

CaseRepresentation
no init or require, never nullable or a reified type argument across the public ABIreadonly struct
has initialisation invariants, or appears in nullable or generic positionsreference type, as on the JVM

@JvmInline is the wrong spelling here. Either value class alone is sufficient, or introduce a target-neutral @ValueClass. Do not add @ClrInline; that way lies four annotations meaning one thing.

7.3 Enums are broken in both directions

Kotlin enums cannot be CLR enums. A CLR enum is a value type over an integer with no members, no constructors and no state. Kotlin's enum class has constructors, properties, methods and per-entry class bodies. So a Kotlin enum maps to a class with static instance fields, as on the JVM, and C# cannot switch over it with enum syntax.

C# enums cannot be Kotlin enums. enum E : int { A, B } permits (E)999. Any integer is a legal value. Importing that as a Kotlin enum class would be a lie: when (e) { A -> …; B -> … } would claim to be exhaustive over a type with other values, and E.values() would be wrong.

The honest import is a value class over the underlying integer:

// imported from C#:  enum FileAccess : int { Read = 1, Write = 2, ReadWrite = 3 }
@ClrEnum
value class FileAccess(val value: Int) {
    companion object {
        val Read = FileAccess(1)
        val Write = FileAccess(2)
        val ReadWrite = FileAccess(3)
        val declared: List<FileAccess> = listOf(Read, Write, ReadWrite)
    }
}

Named constants, a declared list that is honest about being the declared values rather than all of them, and no exhaustive when. Flag enums then work naturally with or and and, which is what [Flags] needs anyway and what Kotlin enums handle badly.

For export, optionally emit a parallel real CLR enum as a [ClrEnumMirror] alongside the Kotlin class, so C# gets switch ergonomics. That is a nicety. The import direction is the part that must be right.

7.4 Exceptions

Throwable → System.Exception is correct and incomplete.

  • The CLR requires thrown objects to derive from System.Exception, which Kotlin satisfies. But the JVM's Error / Exception split vanishes, so code that branches on it — a catch (e: Exception) deliberately not catching OutOfMemoryError — changes behaviour.
  • The CLR's two-pass model with exception filters is more expressive than the JVM's. catch (e: SomeType) if (predicate) has no Kotlin syntax, but filters are the right lowering for coroutine cancellation checks, because a filter runs before any finally unwinds.
  • ExceptionDispatchInfo.Capture().Throw() rethrows with the original stack preserved. Kotlin coroutines do stack-trace recovery by copying exceptions on the JVM; on the CLR this is the supported mechanism and should be used.
  • Task wraps faults in AggregateException when observed synchronously, so every suspend/Task bridge must unwrap or Kotlin callers catch the wrong type.
  • finally guarantees differ at the edges. StackOverflowException is uncatchable and terminates the process without unwinding, so Kotlin code relying on finally in deep recursion behaves differently.

7.5 Declarations

KotlinCLR mappingNote
val / vara real CLR propertymetadata knows about properties; no getName() masquerade
data classEquals/GetHashCode/ToString, IEquatable<T>, DeconstructDeconstruct gives C# positional pattern matching over Kotlin data classes for free. copy has no C# equivalent; leave it Kotlin-only.
sealed classabstract plus a non-public constructor, with the subtype list in the metadata blobexhaustiveness only matters to Kotlin, so metadata suffices
objecta static readonly INSTANCE fielddo not set beforefieldinit. It lets the runtime run the initialiser earlier than first touch, changing Kotlin's initialisation-order guarantees.
companion objectstatic members on the enclosing type, plus a nested Companion type with an instancestatics are what C# expects; the instance is needed because a companion can implement interfaces and be passed as a value
top-level declarationsa FooKt static class, as on the JVMC# reaches them with using static; [Extension] makes the extension functions idiomatic
operator fun plusan instance method plus a static op_Addition forwarderCLR operators are static. Without the forwarder, Kotlin operators are invisible to C# as operators.
vararga params array with [ParamArray]
openvirtualCLR methods are non-virtual by default, matching Kotlin
Array<T> / IntArrayT[] / int[]covariant in both; multi-dimensional int[,] has no Kotlin form and needs a ClrArray2D<T> intrinsic on import

7.6 Collections

Kotlin's collection interfaces should be their own hierarchy that also implements the BCL interfaces, with a wrapping rule at the public boundary.

The reason a simpler answer does not exist: the iterator protocols are structurally different. Kotlin's Iterator is hasNext() then next() — peek, then read. .NET's IEnumerator is MoveNext() then Current — move, then read. Neither can implement the other without holding a buffered element and a state flag. So "mapped types with no adapters" is not achievable as a blanket rule, and the three coherent designs trade differently:

(A) Mapped types. Kotlin's interfaces are the BCL interfaces, as on the JVM where kotlin.collections.List is java.util.List. Best interop, but it requires the BCL surface to cover Kotlin's and it does not. IReadOnlyList<T> has Count and an indexer and nothing else: no indexOf, no subList, no listIterator. Kotlin loses members.

(B) A Kotlin hierarchy with adapters at the boundary. Full fidelity, and every crossing allocates a wrapper. This is an allocation cost the proposed Kotlin/Native-FFI design must avoid.

(C) A Kotlin hierarchy that also implements the BCL interfaces. kotlin.collections.List<T> : IReadOnlyList<T>. Kotlin keeps its whole surface, C# sees a familiar interface, and Kotlin→C# needs no adapter at all.

Take (C), with one boundary rule. A public Kotlin function declaring List<T> emits a CLR signature taking IReadOnlyList<T> and wraps on entry. One small allocation per call, never a copy, and the C# caller passes their own List<string> with no ceremony. Inside Kotlin, everything is the Kotlin hierarchy.

Kotlin declares:   fun render(items: List<Item>)
C# sees:           void Render(IReadOnlyList<Item> items)
on entry:          a zero-copy view, never a copy

The for loop is intrinsic. Kotlin's for desugars to hasNext/next, but that lowering belongs to the compiler, so on the CLR it desugars straight to MoveNext/Current. Iterating a .NET IEnumerable from Kotlin then costs exactly what C# pays. Only code explicitly holding a kotlin.collections.Iterator needs the buffering adapter, which is rare.

Mutability is the direction to be careful about. .NET's convention is that read-only collections implement the mutable interface and throw at runtime; ICollection<T> even has an IsReadOnly property. So importing IList<T> as MutableList<T> is unsound in the same way Collections.unmodifiableList is on the JVM. Prefer IReadOnlyList<T> → List<T> and IList<T> → MutableList<T>, and document that a mutating call can throw.

.NETKotlin
IEnumerable<T>Iterable<T>
IReadOnlyCollection<T>Collection<T>
IReadOnlyList<T>List<T>
IReadOnlyDictionary<K,V>Map<K,V>
ICollection<T>MutableCollection<T>
IList<T>MutableList<T>
IDictionary<K,V>MutableMap<K,V>
ISet<T> / IReadOnlySet<T>MutableSet<T> / Set<T>

8. Concurrency: suspend and Task

Exporting suspend fun as Task<T> is straightforward. Importing Task<T> as suspend fun quietly breaks structured concurrency, and that asymmetry drives the whole design here.

Task is hot. suspend is cold. Calling a Task-returning method starts the work immediately. A suspend fun starts nothing until awaited, inherits the caller's CoroutineContext, and dies when its scope dies. Present an eager Task as a suspend fun and you get a function that looks structured and is not: cancelling the enclosing coroutine leaves the .NET work running, and the caller's dispatcher and job never reach it.

So import the raw shape faithfully and synthesise a suspending facade beside it. The caller chooses.

// C#:  Task<User?> FindUserAsync(Guid id, CancellationToken ct = default)

// imported, raw — honest about eagerness
fun findUserAsync(id: Guid, ct: CancellationToken = CancellationToken.None): Task<User?>

// imported, synthesised facade — structured, cancellation wired
suspend fun findUser(id: Guid): User?

The facade appears only when it can be made honest, which in practice means the method takes a CancellationToken or carries the Async suffix by convention. Everything else stays a Task with an await() extension. The failure mode of guessing is a leaked cancellation scope that nothing reports, so opt-in fidelity wins over automatic magic.

CancellationToken belongs in the core rule. Nearly every real async .NET API takes a trailing token. The bridge links the Kotlin Job to a CancellationTokenSource in both directions: a cancelled coroutine cancels the token it passed in, and a cancelled token cancels the coroutine. Exporting suspend fun to C# means emitting a trailing optional CancellationToken, because a Task the caller cannot cancel is not an idiomatic .NET API.

Flow and IAsyncEnumerable are the clean pairing. Both cold, both cancellable, both pull-with-backpressure. IAsyncEnumerable<T> even takes its token through [EnumeratorCancellation] and WithCancellation, which lines up with Flow's context propagation. Almost no adaptation, and it is the shape most .NET streaming APIs now use. Advertise it.

Dispatchers need a per-host main thread. .NET's await captures SynchronizationContext by default; Kotlin's dispatchers are explicit. Dispatchers.Main on the CLR means the host's UI SynchronizationContext — WPF's, WinUI's, Avalonia's, or Unity's single-threaded-per-frame one. kotlinx.coroutines solves this on the JVM with ServiceLoader, which has no CLR equivalent, and the reflection-based substitutes are exactly what trimming and IL2CPP break. Discover it through assembly-level attributes at build time. See §10.

ValueTask cannot be awaited twice, so the bridge must never re-await one. A @ClrValueTask annotation to opt into a ValueTask<T> return is a reasonable knob for hot paths. The default is Task<T>, and Task for Unit.

suspend fun f(): T      ->  Task<T> F(CancellationToken ct = default)
suspend fun f()         ->  Task F(CancellationToken ct = default)
Flow<T>                 ->  IAsyncEnumerable<T>
@ClrValueTask suspend   ->  ValueTask<T>

None of this requires Kotlin coroutines to become C# async state machines. The continuation can complete a TaskCompletionSource<T>. A specialised CLR coroutine lowering is a later optimisation, which is a good reason to build the bridge first and measure before rewriting it.

9. Consuming .NET: the parts that decide whether this is usable

9.1 Applying attributes is Phase-1 work

Without the ability to apply CLR attributes from Kotlin you cannot write ASP.NET ([HttpGet]), Unity ([SerializeField]), xUnit ([Fact]), Orleans, or Godot ([Export]). Every headline use case needs this on day one.

It takes more than mapping Kotlin annotations to CLR attributes:

  • positional and named constructor arguments, including array and Type arguments;
  • attribute targets Kotlin lacks — assembly, module, return value, and parameter as distinct from the property;
  • generic attributes;
  • [Conditional] semantics.

Reading attributes on import is the easy half and arrives with the metadata reader. Writing them decides whether Kotlin can express a framework's contract at all.

9.2 Events

Kotlin has no event construct. Import as add and remove, which is what the CLR metadata actually is:

// C#:  event EventHandler Changed;
changed.add(handler)
changed.remove(handler)

Do not invent Kotlin grammar for this in v1. An operator fun plusAssign on an event handle later gets close to C#'s += with no language change.

9.3 ref, out, in

Expose them explicitly, as ClrRef<T>, ClrOut<T>, ClrIn<T> or compiler intrinsics. A Kotlin caller must be able to see that a callee can write through its argument, so do not hide aliasing behind ordinary parameters. Overload sets differing only by ref/out/in, or only by nullability, collapse in Kotlin and need disambiguating renames on import.

9.4 Span<T> and ref struct

Real support means the compiler enforcing CLR stack-lifetime restrictions, which is genuine work and not MVP material. But absent is worse than opaque. Modern .NET APIs are Span-first, and for a growing number of them the Span overload is the only overload. An importer that omits ref struct types entirely makes overload resolution silently pick something worse, or makes the method vanish.

So import ref struct types as opaque, unusable-but-visible types from the start, and let a Span<Byte> parameter be a clear compile error instead of a missing member. Full support lands with the performance phase.

9.5 Static abstract interface members

.NET 7 and later express generic math with static abstract interface members: INumber<T>, IAdditionOperators<T,T,T>. Kotlin has no equivalent and cannot implement them. This is not a small gap. It is how modern .NET writes generic numeric code, and it is unreachable without a Kotlin language feature. Consuming a concrete instantiation works; authoring a type that participates does not. State the limitation now instead of discovering it in year two.

9.6 Source generators, and the C# facade

A growing share of .NET frameworks are not libraries you call. They are Roslyn source generators that generate code from your declarations, and this is the most serious ecosystem blocker in the document.

  • Orleans generates grain references and serialisers from your grain interfaces. A Kotlin-authored grain gets none of it, so "Kotlin grains just work" is false.
  • Godot generates signal and [Export] plumbing plus ScriptPath wiring from your node classes.
  • ASP.NET minimal APIs, System.Text.Json AOT, and most MVU frameworks generate their fast paths the same way.

The two-process emitter from §3.2 makes this tractable, because the emitter is a .NET process and can host Roslyn:

Kotlin declarations that need generator treatment
        │
        ▼
emit a C# reference facade (partial classes, attributes, signatures — no bodies)
        │
        ▼
run the framework's Roslyn source generators over the facade
        │
        ▼
compile their output, merge/link into the Kotlin assembly

This works for generators that read attributes and declaration shapes and emit partial members, which is most of them, Orleans and Godot included. It fails for generators that need method bodies, or that emit into a member Kotlin already defined. Scope it honestly: the facade turns "impossible" into "works for the common case, with a known list of exceptions", which is the difference between a demo and a platform.

9.7 Names

Small and pervasive. CLR generic type names carry arity, List1andDictionary2, nested generics compose them, and .NET namespaces are not packages. Kotlin resolution must handle the backtick-arity spelling. And .NET member names collide with Kotlin keywords constantly: object, is, in, val, when, fun, typeof, as. Backtick escaping covers it, provided the importer does it systematically.

10. Metadata, reflection and AOT

Design this reflection-free from the first line. The two markets that justify the project, Unity with IL2CPP and NativeAOT, both do whole-program analysis and trimming, and both punish reflection. A Kotlin that arrived with a kotlin-reflect-shaped runtime dependency would be dead on arrival in exactly the places it is meant to win.

FeatureImplementationTrim-safe
::classldtoken plus Type.GetTypeFromHandleyes — static, resolved at compile time
KClass.simpleName / qualifiedNameconstants baked at the use siteyes
KClass.members, KCallable, full reflectionopt-in reference to kotlin-reflect-clrno, and it should be obvious that it is not
Kotlin metadata for the compilerattribute plus resource blob, read with System.Reflection.Metadata at build timeyes — no runtime reflection at all
service discovery (dispatchers, platform pieces)assembly-level attributes read by the emitteryes

The metadata design follows. Kotlin/JVM's @Metadata is read reflectively at runtime by kotlin-reflect and by tools. On the CLR the blob should be readable by a build-time reader, an MSBuild task or the emitter itself, so trimming can remove everything nobody statically references. The information carried is the same as on the JVM:

suspend                     inline
Kotlin internal visibility  value-class representation
sealed subtype list         default argument expressions
declaration-site variance   extension receivers
expect/actual pairing        context parameters

Emitted as [KotlinMetadata] plus a resource, which C# ignores and Kotlin/CLR uses to reconstruct the exact Kotlin declaration. Kotlin→CLR→Kotlin is then lossless, while Kotlin→CLR→C# sees a clean CLS-compliant API.

11. Build, packaging and debugging

This is the adoption bottleneck. A Gradle-only Kotlin/CLR is unusable in a .NET shop, and a .NET shop is the entire point.

  • NuGet is the dependency graph. The Kotlin compile "classpath" is a set of reference assemblies resolved from NuGet. Either Gradle learns to restore NuGet, or the build is driven from MSBuild and hands the compiler a /reference: list the way csc is driven. The second is less work and more idiomatic, so do that first.
  • Ship an MSBuild SDK or a NuGet-packaged task so a .csproj can ProjectReference a Kotlin project. Until a C# developer can add a Kotlin library without learning Gradle, adoption is zero.
  • Publish Kotlin libraries as .nupkg with a proper lib/net8.0/ layout and reference assemblies. Reference assemblies also keep incremental builds fast.
  • Portable PDBs are Phase-2 work. Sequence points, local variable names, [DebuggerDisplay] on the Kotlin-shaped types, Source Link. Nobody adopts a language they cannot step through in Visual Studio or Rider, and retrofitting debug info after the lowerings exist is materially harder than emitting it alongside them.
  • InternalsVisibleTo for test source sets and friend modules, as in §4.
  • Assembly identity: strong naming, deps.json, runtimeconfig.json, [assembly: TargetFramework]. Unglamorous, and an assembly without them does not load.

12. How this gets tested

Four suites, and the first is nearly free.

  1. Kotlin's own box tests. compiler/testData/codegen/box holds thousands of small programs whose contract is "returns OK". This is how Wasm and Native validated themselves. Pointing the CLR backend at it converts a vague "does the backend work" into a percentage that goes up every week. Build it on day one; it is the project's only honest progress metric.
  2. Differential JVM/CLR execution. The same Kotlin source compiled to both targets, stdout compared. This catches the §5.2 family — shift masking, rounding mode, string ordering, hash stability — which box tests pass because they never compare across targets.
  3. Metadata round-trip. Kotlin → CLR → Kotlin, asserting declaration-level equality. The acceptance test for §10.
  4. Golden public API. Emit an assembly, compile a C# consumer against it, diff the observed public API surface against a checked-in baseline. This is what keeps the ABI decisions in §4 from eroding one convenience at a time.

Plus a CIL conformance corpus, which is really milestone zero.

13. Sequencing

Three constraints set the order. The ABI has to be decided before the lowerings exist. expect/actual has to work before any real Kotlin compiles. And the cheapest first host is not a game engine.

Milestone 0 — the ABI, written by hand

Before any backend code, hand-write the CIL and metadata for about twenty Kotlin declaration shapes: a class with properties, a data class, an object, a companion, an extension function, a default-argument function, a suspend function, a value class, a sealed hierarchy, a generic class, a covariant interface, an operator, a Flow-returning function. Write them with ilasm or MetadataBuilder, consume each from C# and F#, and look at the result in IntelliSense.

Freeze the results as golden files. That corpus is the ABI specification, and it exists before the first lowering instead of being discovered by it. This is the highest-value week in the project.

Milestone 1 — Hello.dll

class Hello {
    fun add(a: Int, b: Int): Int = a + b
}

producing Hello.dll, consumed by:

var h = new Hello();
Console.WriteLine(h.Add(2, 3));

Needs a minimal backend.clr, the module-description protocol, the emitter, and portable PDBs from the start. When this runs with no IKVM anywhere in the pipeline, the core thesis is proven.

Milestone 2 — the importer, and expect/actual

fir-clr reading real assemblies lazily, with nullability enhancement and attribute application. In parallel: klib and metadata compilation, so commonMain works. No real Kotlin code compiles without expect/actual, so deferring it means spending the whole middle of the project on code that resembles nothing anyone writes.

Milestone 3 — kotlin-stdlib-clr on the BCL

Kotlin's API surface implemented over the base class library, with every Kotlin type resolving to a BCL type by intrinsic:

kotlin.String  ->  System.String

Nothing routes through java.lang.String and a Java compatibility layer to get there. That path works, and it is what IKVM does; it also means every string in the program carries a Java runtime behind it.

kotlin.io           -> System.IO
kotlin.text         -> System.Text
kotlin.concurrent   -> System.Threading
kotlin.collections  -> System.Collections.Generic  (per §7.6 option C)
kotlin.math         -> System.Math, with §5.2 corrections

The runtime stays thin and every type a C# caller sees is one they already know. atomicfu lands here too: the stdlib needs atomics, and coroutines needs atomicfu (§14.1).

Milestone 4 — idiomatic interop

Properties, delegates as function types, nullable types, extension methods, generics and variance, operators, data-class Deconstruct. This is where the language stops being a curiosity and becomes pleasant.

Delegates deserve a note. Func<Int, String> should be usable as (Int) -> String with a compiler-generated conversion, while custom delegates keep their nominal CLR type and get SAM-like conversion at the call site. Flattening every delegate into FunctionN throws away the type name the API author chose, and .NET code is written around named delegates.

Milestone 5 — concurrency, and kotlinx-coroutines-core

All of §8: the Task bridge with CancellationToken wired both ways, Flow over IAsyncEnumerable, dispatchers over SynchronizationContext, AggregateException unwrapping. And the coroutines port itself — the dispatcher layer, runBlocking, the test dispatchers, and the ExecutionContext decision in §14.2. The bridge and the library are one piece of work; splitting them means building a bridge against a runtime that does not exist yet.

Milestone 6 — first host: a plain net8.0 console and ASP.NET app

No engine, no AOT, no trimming, no source generators. A Kotlin class registered in an ASP.NET DI container, handling a route, calling EF Core. The cheapest possible real host, and it exercises attributes, async, collections and NuGet consumption at once.

It also forces the rest of Tier 1 (§14.4): kotlinx.serialization with its System.Text.Json adapter, logging into Microsoft.Extensions.Logging, and the kotlin.test to xUnit adapter. You cannot write a service without those three, so this is where they become real.

Milestone 7 — Godot

Godot's .NET edition is plain net8.0 with a NuGet SDK, and its documentation states that any CLI-compliant language can in principle be used, with C# simply being the supported one. Its architecture is more open and more explicit than Unity's. It does use source generators for [Signal] and [Export], so this is where the facade in §9.6 gets proven.

class Player : Node3D() {
    override fun _process(delta: Double) {
        rotateY(delta)
    }
}

Milestone 8 — Unity

Unity is the most visible demo and the hardest host, which is why it lands late. It needs everything above plus IL2CPP AOT constraints, reflection stripping, Unity's own serialisation of Kotlin-declared fields, .csproj and asmdef generation that assumes C#, and editor-side type discovery. The timing is favourable: Unity's own upgrade guidance puts CoreCLR in the Unity 6.x line with Mono removed, so the target is a standard runtime instead of Unity-flavoured Mono forever. Favourable is not cheap.

class PlayerController : MonoBehaviour() {
    lateinit var movement: InputPort<Vector3>
    override fun Update() { movement.emit(...) }
}

All of it belongs in a separate kotlin-clr-unity layer, outside the backend.

Milestone 9 — performance and AOT

Structs, Span<T>, ref/out/in, SIMD, NativeAOT. On why NativeAOT is not redundant with Kotlin/Native: Kotlin/Native gives you Kotlin plus C libraries, while Kotlin/CLR plus NativeAOT gives you Kotlin plus the entire .NET ecosystem as a single native executable. For Reaktor CLI tools and servers those are different products.

14. The libraries this needs

A Kotlin target without coroutines and serialisation is a compiler demo. Budget for them alongside the backend.

The cost is not proportional to line count. These libraries are commonMain-heavy with small platform layers, so the work concentrates in a few specific places. And the CLR's base class library is large enough that some of the list is deletion rather than porting.

One hard rule: add source sets, never fork commonMain. A port that touches common code cannot be upstreamed, cannot track releases, and becomes a permanent liability. If a port seems to need a common-code change, that change is an upstream PR.

14.1 atomicfu comes first

kotlinx.atomicfu sits at the bottom of the stack. Coroutines depends on it, and so does almost everything else concurrent. It is a compiler plugin plus a thin runtime that rewrites atomic field references into the target's real primitives: AtomicFieldUpdater on the JVM, plain fields on JS, native atomics on Kotlin/Native.

On the CLR the primitives are System.Threading.Interlocked and Volatile, which cover compare-and-set, exchange, add, and volatile read and write for every shape atomicfu needs. Because the transform is an IR-level rewrite rather than an algorithm, most of the work is a new intrinsic mapping. This is the smallest Tier-0 port and it blocks the largest, so it lands with the stdlib at Milestone 3 and never behind coroutines.

14.2 Coroutines

kotlinx-coroutines-core needs design rather than translation. The commonMain machinery — jobs, scopes, cancellation, channels, flows, select — is portable. The platform layer holds the decisions.

PieceCLR substrateDecision
Dispatchers.DefaultThreadPoolUse it directly. .NET's pool is already work-stealing with hill-climbing, so the JVM's bespoke CoroutineScheduler does not need porting. This layer gets thinner.
Dispatchers.IOa dedicated elastic poolDo not use the .NET ThreadPool. Kotlin's IO exists for blocking calls, and blocking .NET's pool threads starves Kestrel and everything else sharing it. This dispatcher needs its own threads.
Dispatchers.Mainthe host's SynchronizationContextWPF, WinUI, Avalonia, Unity's per-frame context. Discovered through assembly attributes at build time, never reflection — see §10.
delay, timeoutsTimeProvider / PeriodicTimerTimeProvider is .NET 8's clock abstraction, so Kotlin's virtual-time test dispatchers and .NET's fake clocks can be the same clock. A free interop win for tests.
runBlockinga temporary single-threaded SynchronizationContextWithout this it deadlocks exactly the way task.Result does on a UI or ASP.NET context. A known .NET trap with a known fix, applied deliberately.
ThreadContextElement[ThreadStatic] save and restoreAsyncLocal<T> is a different propagation model and will not do.
DebugProbes, coroutine dumpsdrop, or reimplementThe JVM version needs a bytecode agent.
kotlinx-coroutines-testas aboveNon-negotiable. Without it nobody can test coroutine code, so nobody adopts it.

The ExecutionContext decision matters most. .NET carries a per-logical-flow context that Kotlin's CoroutineContext knows nothing about, and it holds what a host depends on: AsyncLocal<T> values, the ASP.NET request context, ILogger scopes, CultureInfo, and the Activity that OpenTelemetry traces ride on. A Kotlin coroutine that resumes without restoring it loses all of that. The symptom is not an exception. It is logs with no correlation id, traces that stop at the Kotlin boundary, and a request context that is null halfway through a handler. Very hard to diagnose, trivially avoided.

So capture ExecutionContext at suspension and restore it at resumption, by default. It costs an ExecutionContext.Run per resumption, measurable but small, and it is what makes Kotlin coroutines a well-mannered .NET citizen instead of a foreign runtime that happens to share the process. Provide an opt-out for hot paths that provably do not need it.

14.3 Serialisation

kotlinx.serialization splits into a compiler plugin and a set of runtime formats, and both are cheaper than they look. The plugin generates serialisers from @Serializable at the FIR/IR level, so it is largely target-agnostic; it needs the CLR backend to emit what it generates. The formats — JSON, CBOR, ProtoBuf — are close to pure commonMain.

Two things worth spelling out.

Generated serialisation is an advantage here. System.Text.Json in its default reflection mode is exactly what trimming and IL2CPP break, which is why .NET added a source generator for it. kotlinx.serialization has been generated rather than reflective from the start, so on NativeAOT and Unity it arrives already solving the problem the host ecosystem is still migrating toward. A real selling point for the AOT story.

Ship a System.Text.Json adapter. A JsonConverter that delegates to a Kotlin type's generated serialiser lets @Serializable classes drop straight into an ASP.NET request pipeline, an HttpClient call, or a JsonSerializerOptions chain, with no duplicate [JsonPropertyName] annotations and no second model. Small adapter, and it removes the most common reason anyone would hand-write DTOs twice.

14.4 Tier 1: needed for anything real

LibraryCLR substrateNote
kotlinx-datetimeDateTimeOffset, TimeZoneInfo, DateOnly/TimeOnlyInstant and LocalDateTime map cleanly. TimeZone must delegate to TimeZoneInfo instead of carrying its own tz database, or the two halves of a process disagree about time.
kotlin.testan xUnit or NUnit adapterCheap, and without it Kotlin tests are invisible to dotnet test and Rider's runner, which means invisible to .NET CI.
loggingMicrosoft.Extensions.LoggingNon-negotiable for ASP.NET. Kotlin log calls must land in the host's ILogger with the host's scopes intact, which is another reason §14.2's ExecutionContext decision goes the way it does.
kotlinx-io and Okio-shaped buffersStream, Span<T>, Memory<T>, ArrayPool<T>, System.IO.PipelinesPipelines is a better substrate for segmented buffered IO than anything on the JVM.
Ktor clientan HttpClient-backed engineWorth doing. One engine, and Kotlin code keeps its idiomatic HTTP API.
Ktor serverskip itOn the CLR the host is Kestrel and ASP.NET. A second HTTP server beside it is work with a worse outcome; expose Kotlin handlers to ASP.NET instead. The largest single scope saving on this list.
kotlinx-collections-immutableSystem.Collections.Immutable interop, or a straight portSmall either way.
kotlinx-rpcthe existing transportsAlready a named trunk in the FFI design.
DI registrationMicrosoft.Extensions.DependencyInjectionAn adapter. Reaktor has its own capability and graph model.

The list is shorter than expected because the BCL already covers the rest. Much of what the Kotlin ecosystem provides on the JVM is first-party on .NET: HTTP, JSON, DI, logging, crypto, pipelines, immutable collections, a work-stealing thread pool, a clock abstraction. On this target those are libraries not to need. Tier 0 plus the table above, and then it stops.

14.5 Tier 2: Reaktor's own modules

Every Reaktor module with platform code grows a clrMain: core, graph, flow, service, flexbuffer, db, auth, ffi. Two observations.

reaktor-flexbuffer's CLR actual may end up the fastest one. Span<T>, Memory<T>, ArrayPool<T> and stackalloc are a better substrate for zero-copy frame building than JVM ByteBuffer, and the copy ledger that FFI trunks are measured against should be easier to hold at zero here than on the JVM.

The harness debt is real. The expect/actual surface grows another actual for every platform declaration, against a codebase that already has twelve untested modules (docs/private/reaktor-prune-regularisation). The harness-first rule from that plan applies here more than anywhere: a clrMain with no tests is worse than no clrMain, because it looks like reach and behaves like debt.

14.6 Getting them, and upstreaming them

Library maintainers will not add a source set for a target that does not exist in the compiler yet, so the order is fixed. The target lands first, the ports follow, the upstream PRs come once the target is real enough to be worth supporting.

Until then, keep the ports in one tracked kotlin-clr-ecosystem repository: one source set per library, pinned to an upstream tag, published under distinct coordinates, with a rebase cadence like the compiler fork's (§16). Under the source-sets-only rule each port stays a small diff against a moving target, which is the difference between maintaining six source sets and maintaining six forks.

The other half of the ecosystem question is the .NET libraries Kotlin must consume — EF Core, ASP.NET, HttpClient, the Unity and Godot APIs — which is §9's subject. Note the asymmetry: consuming is where the reach comes from, and it needs nobody's cooperation.

15. IKVM's real job

IKVM translates JVM bytecode to CIL and lets .NET projects consume the result, so this pipeline genuinely works today:

Kotlin -> Kotlin/JVM -> .class/.jar -> IKVM -> .NET DLL -> C#

As a first milestone it is a trap, because what it actually produces is:

Kotlin semantics -> JVM semantics -> IKVM's Java compatibility runtime -> CLR

Translated bytecode still expects JDK semantics, so IKVM carries a Java runtime layer with it. Every API it exposes is Java-shaped: getName() where a property belongs, java.util.List where IReadOnlyList belongs, checked exceptions, java.lang.String conversions. Nothing is learned from cataloguing those, because the catalogue is the Kotlin/JVM ABI, which is already documented and already understood. And the cost runs past the wasted time: a working demo with a JDK inside it creates real pressure never to build the real thing.

IKVM's actual job is a compatibility lane, and it is a valuable one. §14 lists the libraries that get a real clrMain. IKVM covers the long tail that never will — JVM-only Java and Kotlin libraries, and anything with a transitive JDK dependency. It lets those be consumed from a Kotlin/CLR program while their maintainers catch up, with the Java runtime layer as an explicit and visible cost. Positioned that way it is a migration tool, which is what it is good at.

16. Cost, risk and kill criteria

Be honest about scale. Kotlin/Wasm took JetBrains a team and several years.

OutcomeOrder of magnitude
Milestone 0 plus Hello.dllweeks
Importer and stdlib enough to write a real ASP.NET servicemany months
Box-test suite mostly green, debuggable, NuGet-shippablea year or more, with more than one person
A Unity or Godot story someone ships a game onmulti-year

The risks that decide this, in order.

  1. The library ecosystem is a schedule risk. Coroutines, serialisation and the Tier-1 list in §14 need nobody's permission: the source sets can be written and maintained independently under the source-sets-only rule, and upstreamed later. What is at risk is when. The gates are mechanical — atomicfu green with the stdlib at Milestone 3, coroutines green at Milestone 5, Tier 1 green at Milestone 6.
  2. Fork maintenance against K2. The Kotlin compiler moves fast. Keep the diff surface minimal — a new module, no edits to shared code — upstream small hook PRs, and commit to a rebase cadence from the start. A fork that falls six months behind is dead, and it looks alive for a while first.
  3. Source generators. If the facade in §9.6 does not work for Orleans and Godot, two of the three best use cases shrink substantially. Prototype it against Orleans before committing to Milestone 6. It is cheap to test and it is load-bearing.
  4. The ref struct frontier. If modern .NET APIs go Span-only faster than the compiler learns stack lifetimes, the usable API surface stops growing.

Kill criteria, stated in advance so they can actually be applied: no coroutines story by Milestone 5; no working Orleans or Godot generator path by Milestone 7; rebase cost against upstream sustained above a day a week.

One structural decision belongs here. A compiler plugin is enough for a spike — Kotlin exposes IrGenerationExtension — but a plugin sees IR before the backend lowerings and would have to reimplement them. A new output architecture is a backend. Fork, add org.jetbrains.kotlin.backend.clr with ClrBackendContext, ClrSymbols, ClrTypeMapper, ClrLoweringPipeline, ClrCodegen and ClrModuleWriter, and mirror backend.jvm closely. The mirroring exercise is itself the fastest way to learn which lowerings are genuinely JVM-specific.

17. What this means for Reaktor

Reaktor gets two .NET stories. They arrive years apart and they complement each other. The FFI switchboard gives C# a bridged seat and lands first. Kotlin/CLR makes C# first-party, and that is where the reach comes from.

What it isCostThe seat C# getsDoes not give you
R1A C# binding over the FFI switchboard six-function C ABI, via DllImportweeks, once the C ABI lands — it is P3 in the FFI plan, designed and unshippedbridged: Reaktor graph callable from C#, C# cells callable from Kotlin, FlexBuffer frames, streams with creditsone heap, shared object identity, subclassing host types
R2An rgp/1 client in C#days to weeks after rgp/1 existsobserved: the workbench sees and drives a running .NET process's graphin-process execution
R3Kotlin/CLRmulti-year, see §16first-party: one heap, one GC, zero marshalling, C# types that are nodes, Kotlin classes that are MonoBehaviour / Grain / Node3D—

The FFI document already names C# as a language that "binds [the C ABI] in an afternoon". It is a listed, unbuilt lane on an existing roadmap, which is exactly why it should go first.

Bridged is not first-party

R1 is genuinely useful and it arrives years earlier. What it cannot be is first-party. A C# cell behind the switchboard is a foreign participant. It speaks in frames. Its arguments are copies, or borrowed buffers with an ownership contract to get wrong. Its lifetime is a handle with a generation counter. It lives in a different heap behind a documented process-trust boundary. Everything works, and everything is mediated.

Kotlin/CLR removes the mediation:

  • a C# class can be a Reaktor node, instead of a cell reachable through one;
  • a Kotlin node holds a direct reference to a C# object — same heap, same GC, same object identity, no frame, no copy, no ownership contract;
  • every NuGet package becomes a Reaktor dependency, instead of something on the far side of a bridge;
  • every .NET host becomes a Reaktor host, including the ones that cannot host a bridge at all;
  • a C# developer authors Reaktor nodes in C#, as a peer of Kotlin, with no bridge to learn and no frame protocol to understand.

That last point is the reach argument, and R1 cannot make it. Reaktor's languages fall into two tiers. First-party, where a type in that language is a node. Bridged, where a type in that language is reachable through a trunk. Today Kotlin is alone in the first tier. R3 promotes C# into it, the only language other than Kotlin to get there, and F# and every other CLS language with it, while TypeScript, Swift, Python and C++ stay bridged behind FFI trunks. The size of that promotion is the whole argument for the project.

Which hosts have no bridged answer at all

Sequencing turns on one question: does the host make you author host types?

  • ASP.NET, Orleans, EF Core, Azure SDKs are mostly calling a host. R1 gets this working sooner, at the cost of a shared heap and marshalling per call. R3 upgrades it to first-party.
  • Unity, Godot, Revit, AutoCAD, WPF and every plugin host make you subclass MonoBehaviour, Node3D, IExternalCommand, Window. A bridge cannot do it at any price. These have no R1 answer.

So: ship R1 and R2 first, because they are weeks of work on already-planned surfaces and they make C# useful immediately. Build R3 because it is the only thing that makes C# first-party, and because the engines and plugin hosts have no other answer.

What R3 is worth is real. A Reaktor node running inside an existing .NET process:

one ASP.NET / Unity / Revit process
──────────────────────────────────────
C# controllers and libraries
Kotlin Reaktor graph
F# components
EF Core
one GC, one heap, one thread pool
──────────────────────────────────────

Same object identity, no serialisation, no P/Invoke, no JNI. And the multi-language graph becomes literal instead of aspirational. Reaktor graphs do not care what language produced a node, so a CLR-hosted graph could hold Kotlin, C# and F# nodes in one process while the same graph model elsewhere holds TypeScript, Swift and C++ nodes behind FFI trunks. That is the version of the thesis worth stating — Reaktor describes the system; Kotlin is the best language for authoring it, and on the CLR C# becomes the second — with R1 getting the server case working years earlier and R3 making it first-party when it lands.

18. Use cases, re-ranked

Ranked by the question in §17, not by market size. The third column says what R3 does for each row: enables it where no bridge can, or upgrades an already-reachable case from bridged to first-party.

#Use caseR3Why it ranks here
1UnityenablesThe most visible demo, and only a real backend can subclass MonoBehaviour and get Unity serialisation over Kotlin fields. Hardest host, so late in the plan and first in value.
2GodotenablesCleanest technical validation: standard net8.0, an open .NET architecture, and documentation that already says any CLI language should work. The right place to prove the generator facade.
3CAD / BIM / plugin hosts — Revit, AutoCAD, Rhino/Grasshopper, industrial toolingenablesPlugin APIs are C# interfaces you must implement. Typed graph plus agents plus domain tools is a genuine fit, and these markets pay far more per seat than developer tooling.
4Windows desktop — WinUI, WPF, Windows App SDK, COMenablesFills Kotlin's weakest ecosystem position. The interesting form is hosting Reaktor inside an existing Windows app, leaving Compose alone.
5Incremental Kotlin adoption in C# organisationsenablesThe Kotlin-on-JVM story repeated: Billing.cs, Orders.cs and Fraud.kt all compiling into Company.Product.dll. Adoption one file at a time is why Kotlin won on the JVM, and R1 cannot offer it.
6ASP.NET / enterprise .NETupgradesCommercially the largest, and the one R1 reaches first. services.AddReaktor(...) over a bridge works; over a shared heap it stops being an integration and becomes a library. Ranked below the engines because it has an earlier answer, not because it matters less.
7Orleans and distributed .NETupgradesAttractive, and gated on §9.6. Until the facade is proven against Orleans, "Kotlin grains" is a hypothesis; a bridged Reaktor graph inside an Orleans silo is not.
8AvaloniaupgradesUseful once R3 exists, though Compose Multiplatform already covers the need. The value is interop with existing Avalonia codebases.
9NativeAOT deploymentenablesA later deployment option for Reaktor CLI tools and servers: Kotlin plus the .NET ecosystem as one native binary.

The framing worth keeping: this is not "Kotlin for Unity". Build Kotlin/CLR, and let Unity be the first spectacular consequence.

                    Kotlin/CLR
                        │
             Common Language Runtime
                        │
    ┌────────────┬──────┼──────┬────────────┐
    │            │      │      │            │
  Unity        Godot  ASP.NET Windows     Reaktor
    │            │      │      │            │
 games        games  servers desktop     everything

19. What to read, in the order the decisions arrive

Months of preparatory reading would be a mistake. Each item below answers a specific decision in this document, so read it against that decision.

Already in Manna

Crafting Interpreters (Nystrom) for the mental model of source to IR to runtime to object model. Chapters 14 and 15 (bytecode, VM), 18 (values), 24 and 25 (calls, closures), 26 (GC), and 27 to 29 (classes, methods, inheritance). You are not writing an interpreter. You are borrowing its picture of an object model.

Engineering a Compiler (Cooper & Torczon) is the most directly useful book you already have. Chapters 5 (IRs), 6 (procedure abstraction), 7 (code shape), 8 (optimisation), 9 (data-flow) and 11 (instruction selection). Skim scanning and parsing entirely. K2/FIR already solves the frontend, and this project starts at the IR.

C# in Depth (Skeet) for the divergences, not the syntax. Its chapters map one to one onto the hardest sections here:

generics chapter      -> §7.1 variance and constraints
delegates chapter     -> §13 milestone 4
async chapter         -> §8 the Task bridge
nullable chapter      -> §6 nullability
pattern matching      -> §7.5 Deconstruct

Microsoft Orleans is not needed to build the compiler. It is needed to evaluate the risk in §9.6: read it to understand what its source generators produce, because that is what tells you whether a Kotlin grain can work.

Missing, and core

ECMA-335, Common Language Infrastructure is the specification you implement against, the JVM spec of this project. Partition I (CTS, CLS, execution model), Partition II (metadata, types, signatures, generics), Partition III (the CIL instruction set). Read it while implementing, one table at a time. Six hundred pages front to back would be wasted.

Book of the Runtime, in dotnet/runtime, may be more useful than any textbook here, because it was written to explain CLR invariants to people working on the runtime. The type system chapter goes down to MethodTable, MethodDesc, FieldDesc, type loading and casts. There is an explicit CLR ABI document covering runtime and JIT conventions. The generics, GC and exception-handling chapters answer most of §7 directly.

The Kotlin compiler source becomes the main text eventually. Compare backend.jvm, backend.native and backend.wasm, and read fir-java as the model for §3.1. JVM teaches you how Kotlin maps onto another managed runtime. Native teaches you what has to change when the target object model differs. Together they are most of the CLR blueprint.

The order

Crafting Interpreters (VM + object model)
        ↓
Engineering a Compiler (IR, procedures, code shape)
        ↓
C# in Depth (generics, delegates, async, nullability)
        ↓
ECMA-335 (CTS -> metadata -> CIL)          <- while writing Milestone 0
        ↓
Kotlin backend.jvm and fir-java
        ↓
emit Hello.dll                             <- Milestone 1
        ↓
Book of the Runtime                        <- while implementing semantics
        ↓
Orleans source generators                  <- before committing to Milestone 6

20. Open questions

  1. What is the real ExecutionContext cost? The capture-and-restore decision in §14.2 is the difference between Kotlin coroutines working inside ASP.NET and silently losing the request scope. Measure it on a real handler before deciding whether the opt-out is needed.
  2. Does option C in §7.6 survive contact with a real API? Pick ten heavily used BCL signatures and check what a Kotlin caller and a C# caller each see.
  3. Does the facade work on Orleans? Cheap to test, load-bearing for two use cases.
  4. Struct-mapped value classes: accept the default(T) hole, or restrict the mapping? C# lives with the same hole for its own types, which argues for accepting it.
  5. Runtime null checks at the public boundary: on by default, or configurable? Leaning on, for the reason in §6.
  6. Should internal really map to internal? It is more faithful than the JVM, and it makes a Kotlin module boundary an assembly boundary, which constrains how Reaktor's modules are packaged.
  7. Who maintains kotlin-stdlib-clr against an expect surface that moves every release?
  8. Naming. "Kotlin/CLR" is descriptive and sits beside Kotlin/JVM, Kotlin/JS, Kotlin/Native and Kotlin/Wasm. The compiler is Apache-2 so a fork is fine, but the name is a trademark question to answer before anything is published.