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.
- 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
.dllmetadata and applying .NET attributes matters enormously. Polish on the export side matters much less. - 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
clrinteroptool 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. - 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.
- 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'sExecutionContext. It must. See §14. - 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:
| Piece | Java counterpart | Job |
|---|---|---|
ClrClassFinder | JavaClassFinder | resolve an assembly-qualified name to a metadata handle |
ClrSymbolProvider | JavaSymbolProvider | build FIR symbols lazily from ECMA-335 tables |
ClrTypeEnhancement | Java type enhancement | apply [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 information | Where it goes |
|---|---|
suspend, internal visibility, sealed hierarchy, variance, expect/actual, inline, value-class representation | a [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.
| Kotlin | CLR | Note |
|---|---|---|
Any | System.Object | |
String | System.String | both UTF-16, both immutable, both sealed |
Boolean | System.Boolean | |
Byte | System.SByte | signed, as in Kotlin |
UByte | System.Byte | |
Short / UShort | System.Int16 / UInt16 | |
Int / UInt | System.Int32 / UInt32 | |
Long / ULong | System.Int64 / UInt64 | |
Float / Double | System.Single / Double | |
Char | System.Char | UTF-16 code unit in both |
Throwable | System.Exception | see §7.4 |
Unit | void in return position, a Kotlin.Unit struct elsewhere | Task<void> is not a type |
Nothing | void 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:
NullableAttributeandNullableContextAttributeare 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 unconstrainedTand aT?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.checkNotNullParameterat 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 anullreaches 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:
| Case | Representation |
|---|---|
no init or require, never nullable or a reified type argument across the public ABI | readonly struct |
| has initialisation invariants, or appears in nullable or generic positions | reference 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'sError/Exceptionsplit vanishes, so code that branches on it — acatch (e: Exception)deliberately not catchingOutOfMemoryError— 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 anyfinallyunwinds. 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.Taskwraps faults inAggregateExceptionwhen observed synchronously, so everysuspend/Taskbridge must unwrap or Kotlin callers catch the wrong type.finallyguarantees differ at the edges.StackOverflowExceptionis uncatchable and terminates the process without unwinding, so Kotlin code relying onfinallyin deep recursion behaves differently.
7.5 Declarations
| Kotlin | CLR mapping | Note |
|---|---|---|
val / var | a real CLR property | metadata knows about properties; no getName() masquerade |
data class | Equals/GetHashCode/ToString, IEquatable<T>, Deconstruct | Deconstruct gives C# positional pattern matching over Kotlin data classes for free. copy has no C# equivalent; leave it Kotlin-only. |
sealed class | abstract plus a non-public constructor, with the subtype list in the metadata blob | exhaustiveness only matters to Kotlin, so metadata suffices |
object | a static readonly INSTANCE field | do not set beforefieldinit. It lets the runtime run the initialiser earlier than first touch, changing Kotlin's initialisation-order guarantees. |
companion object | static members on the enclosing type, plus a nested Companion type with an instance | statics are what C# expects; the instance is needed because a companion can implement interfaces and be passed as a value |
| top-level declarations | a FooKt static class, as on the JVM | C# reaches them with using static; [Extension] makes the extension functions idiomatic |
operator fun plus | an instance method plus a static op_Addition forwarder | CLR operators are static. Without the forwarder, Kotlin operators are invisible to C# as operators. |
vararg | a params array with [ParamArray] | |
open | virtual | CLR methods are non-virtual by default, matching Kotlin |
Array<T> / IntArray | T[] / 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.
| .NET | Kotlin |
|---|---|
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
Typearguments; - 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 plusScriptPathwiring from your node classes. - ASP.NET minimal APIs,
System.Text.JsonAOT, 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.
| Feature | Implementation | Trim-safe |
|---|---|---|
::class | ldtoken plus Type.GetTypeFromHandle | yes — static, resolved at compile time |
KClass.simpleName / qualifiedName | constants baked at the use site | yes |
KClass.members, KCallable, full reflection | opt-in reference to kotlin-reflect-clr | no, and it should be obvious that it is not |
| Kotlin metadata for the compiler | attribute plus resource blob, read with System.Reflection.Metadata at build time | yes — no runtime reflection at all |
| service discovery (dispatchers, platform pieces) | assembly-level attributes read by the emitter | yes |
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 waycscis driven. The second is less work and more idiomatic, so do that first. - Ship an MSBuild SDK or a NuGet-packaged task so a
.csprojcanProjectReferencea Kotlin project. Until a C# developer can add a Kotlin library without learning Gradle, adoption is zero. - Publish Kotlin libraries as
.nupkgwith a properlib/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. InternalsVisibleTofor 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.
- Kotlin's own box tests.
compiler/testData/codegen/boxholds 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. - 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.
- Metadata round-trip. Kotlin → CLR → Kotlin, asserting declaration-level equality. The acceptance test for §10.
- 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.
| Piece | CLR substrate | Decision |
|---|---|---|
Dispatchers.Default | ThreadPool | Use 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.IO | a dedicated elastic pool | Do 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.Main | the host's SynchronizationContext | WPF, WinUI, Avalonia, Unity's per-frame context. Discovered through assembly attributes at build time, never reflection — see §10. |
delay, timeouts | TimeProvider / PeriodicTimer | TimeProvider 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. |
runBlocking | a temporary single-threaded SynchronizationContext | Without 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 restore | AsyncLocal<T> is a different propagation model and will not do. |
DebugProbes, coroutine dumps | drop, or reimplement | The JVM version needs a bytecode agent. |
kotlinx-coroutines-test | as above | Non-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
| Library | CLR substrate | Note |
|---|---|---|
kotlinx-datetime | DateTimeOffset, TimeZoneInfo, DateOnly/TimeOnly | Instant 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.test | an xUnit or NUnit adapter | Cheap, and without it Kotlin tests are invisible to dotnet test and Rider's runner, which means invisible to .NET CI. |
| logging | Microsoft.Extensions.Logging | Non-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 buffers | Stream, Span<T>, Memory<T>, ArrayPool<T>, System.IO.Pipelines | Pipelines is a better substrate for segmented buffered IO than anything on the JVM. |
| Ktor client | an HttpClient-backed engine | Worth doing. One engine, and Kotlin code keeps its idiomatic HTTP API. |
| Ktor server | skip it | On 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-immutable | System.Collections.Immutable interop, or a straight port | Small either way. |
kotlinx-rpc | the existing transports | Already a named trunk in the FFI design. |
| DI registration | Microsoft.Extensions.DependencyInjection | An 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.
| Outcome | Order of magnitude |
|---|---|
Milestone 0 plus Hello.dll | weeks |
| Importer and stdlib enough to write a real ASP.NET service | many months |
| Box-test suite mostly green, debuggable, NuGet-shippable | a year or more, with more than one person |
| A Unity or Godot story someone ships a game on | multi-year |
The risks that decide this, in order.
- 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.
- 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.
- 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.
- The
ref structfrontier. 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 is | Cost | The seat C# gets | Does not give you | |
|---|---|---|---|---|
| R1 | A C# binding over the FFI switchboard six-function C ABI, via DllImport | weeks, once the C ABI lands — it is P3 in the FFI plan, designed and unshipped | bridged: Reaktor graph callable from C#, C# cells callable from Kotlin, FlexBuffer frames, streams with credits | one heap, shared object identity, subclassing host types |
| R2 | An rgp/1 client in C# | days to weeks after rgp/1 exists | observed: the workbench sees and drives a running .NET process's graph | in-process execution |
| R3 | Kotlin/CLR | multi-year, see §16 | first-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 case | R3 | Why it ranks here |
|---|---|---|---|
| 1 | Unity | enables | The 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. |
| 2 | Godot | enables | Cleanest 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. |
| 3 | CAD / BIM / plugin hosts — Revit, AutoCAD, Rhino/Grasshopper, industrial tooling | enables | Plugin 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. |
| 4 | Windows desktop — WinUI, WPF, Windows App SDK, COM | enables | Fills Kotlin's weakest ecosystem position. The interesting form is hosting Reaktor inside an existing Windows app, leaving Compose alone. |
| 5 | Incremental Kotlin adoption in C# organisations | enables | The 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. |
| 6 | ASP.NET / enterprise .NET | upgrades | Commercially 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. |
| 7 | Orleans and distributed .NET | upgrades | Attractive, 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. |
| 8 | Avalonia | upgrades | Useful once R3 exists, though Compose Multiplatform already covers the need. The value is interop with existing Avalonia codebases. |
| 9 | NativeAOT deployment | enables | A 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
- What is the real
ExecutionContextcost? 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. - 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.
- Does the facade work on Orleans? Cheap to test, load-bearing for two use cases.
- 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. - Runtime null checks at the public boundary: on by default, or configurable? Leaning on, for the reason in §6.
- Should
internalreally map tointernal? 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. - Who maintains
kotlin-stdlib-clragainst anexpectsurface that moves every release? - 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.
Related serialization and FFI evidence
- Reaktor FFI: proposed bridged .NET participation; the production typed transport remains unimplemented.
- October FlexBuffers review: measured codec costs and open correctness defects.
- From Bytes to Mutable Memory: full wire/ownership/mutable-runtime design input.