<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged kmp</title>
<link>https://coderlegion.com/tag/kmp</link>
<description>Powered by Question2Answer</description>
<item>
<title>Why BasicTextField Fails at Scale — And How I Build Kortex</title>
<link>https://coderlegion.com/31745/why-basictextfield-fails-at-scale-and-how-i-buildkortex</link>
<description>&lt;h3 id=&quot;reaching-sub-millisecond-input-latency-on-100k-line-files-using-kotlin-multiplatform-skia-and-virtualized-canvas-layers&quot;&gt;Reaching sub-millisecond input latency on 100K-line files using Kotlin Multiplatform, Skia, and Virtualized canvas layers&lt;/h3&gt;
&lt;p&gt;If you have ever tried building a serious text or code editor in Jetpack Compose or Compose Multiplatform using &lt;code&gt;BasicTextField&lt;/code&gt;, you already know where the ceiling is.&lt;/p&gt;
&lt;p&gt;Declarative UI frameworks excel at forms, chat inputs, and short documents. However, the moment you load a 50,000-line source file, attach real-time syntax highlighting, spawn multiple cursors, and scroll rapidly at 120Hz, the standard composable pipeline starts gasping for air. Recreating &lt;code&gt;AnnotatedString&lt;/code&gt; instances on every keystroke, measuring thousands of nested layout nodes, and allocating objects on the hot rendering path inevitably leads to garbage collection pauses and dropped frames.&lt;/p&gt;
&lt;p&gt;To solve this properly across &lt;strong&gt;Desktop (JVM), Android, iOS, and WebAssembly (Wasm-JS)&lt;/strong&gt;, I built &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/numq/kortex&quot;&gt;Kortex&lt;/a&gt;&lt;/strong&gt; — an open-source, hardware-accelerated code and text editor engine for Kotlin Multiplatform.&lt;/p&gt;
&lt;p&gt;Here is a deep dive into the architecture behind Kortex, why it bypasses standard Compose text components entirely, and how it achieves locked 60+ FPS rendering on massive documents.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-core-philosophy-zero-composable-per-line-overhead&quot;&gt;1. The Core Philosophy: Zero Composable-Per-Line Overhead&lt;/h3&gt;
&lt;p&gt;In a traditional declarative list (like a &lt;code&gt;LazyColumn&lt;/code&gt; of text rows), every visible line is a separate composable node with its own layout pass, modifier chain, and composition state. When you scroll horizontally and vertically simultaneously, or render cross-line selections and diagnostic squiggles, coordinating state across individual row composables becomes an intractable bottleneck.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Kortex treats the entire editor viewport as a single hardware-accelerated surface.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Instead of delegating text rendering to Compose UI nodes, Kortex drops down directly to &lt;strong&gt;Skia&lt;/strong&gt; (via Skiko) using a platform-specific &lt;code&gt;SkiaCanvas&lt;/code&gt; abstraction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// JVM, iOS, and WasmJS implementation
@Composable
actual fun SkiaCanvas(
    modifier: Modifier,
    onRender: (canvas: Canvas, width: Int, height: Int) -&amp;gt; Unit
) {
    Box(
        modifier = modifier.drawWithCache {
            val width = size.width.toInt()
            val height = size.height.toInt()

            onDrawBehind {
                if (width &amp;lt;= 0 || height &amp;lt;= 0) return@onDrawBehind

                drawIntoCanvas { canvas -&amp;gt;
                    onRender(canvas.nativeCanvas, width, height)
                }
            }
        }
    )
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On Android, &lt;code&gt;SkiaCanvas&lt;/code&gt; binds directly to an embedded &lt;code&gt;SkikoSurfaceView&lt;/code&gt; and &lt;code&gt;SkiaLayer&lt;/code&gt;. State changes that require repaints (such as cursor blinking or text input) trigger frame updates via &lt;code&gt;SideEffect { surfaceView?.scheduleFrame() }&lt;/code&gt;, guaranteeing identical 60+ FPS Skia rendering semantics across all four targets without forcing recompositions across the entire Compose UI tree.&lt;/p&gt;
&lt;h4 id=&quot;the-layered-rendering-pipeline&quot;&gt;The Layered Rendering Pipeline&lt;/h4&gt;
&lt;p&gt;Every frame inside &lt;code&gt;EditorCanvas&lt;/code&gt; is composed of lightweight, pre-calculated &lt;code&gt;Layer&lt;/code&gt; primitives rendered in a strict Z-order pass:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Background Canvas&lt;/strong&gt; (&lt;code&gt;BackgroundLayer&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gutter Background&lt;/strong&gt; (&lt;code&gt;gutterBackgroundLayer&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unified Active Line Highlight&lt;/strong&gt; (&lt;code&gt;HighlightedLineLayer&lt;/code&gt; rendered across the full viewport width)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Isolated Gutter Elements&lt;/strong&gt; (&lt;code&gt;GutterLineLayer&lt;/code&gt;, &lt;code&gt;FoldingMarkersLayer&lt;/code&gt;, &lt;code&gt;GutterSeparatorLayer&lt;/code&gt; clipped to &lt;code&gt;gutterWidth&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Code Viewport Matrix Shift &amp;amp; Clip&lt;/strong&gt; (&lt;code&gt;nativeCanvas.translate(gutterWidth, 0f)&lt;/code&gt; clipped to code width)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Underlay Guides &amp;amp; Markers&lt;/strong&gt; (&lt;code&gt;ColumnRulerLayer&lt;/code&gt;, &lt;code&gt;OccurrencesHighlightLayer&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-Selection Regions&lt;/strong&gt; (&lt;code&gt;SelectionLayer&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Syntax-Highlighted Text Paragraphs&lt;/strong&gt; (&lt;code&gt;TextContentLayer&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Diagnostic Sine-Wave Squiggles&lt;/strong&gt; (&lt;code&gt;DiagnosticsLayer&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blinking Multi-Carets&lt;/strong&gt; (&lt;code&gt;CaretLayer&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Because the line-number gutter is isolated through its own clip bounds and the code area is shifted with matrix translations, horizontal code scrolling never bleeds into the gutter, and the entire viewport paints in a single unified GPU pass.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-backed-by-krope-logarithmic-text-mutations&quot;&gt;2. Backed by Krope: Logarithmic Text Mutations&lt;/h3&gt;
&lt;p&gt;A fast renderer is useless if typing a character copies a 10MB &lt;code&gt;String&lt;/code&gt; in memory.&lt;/p&gt;
&lt;p&gt;Under the hood, Kortex is powered by &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/numq/krope&quot;&gt;Krope&lt;/a&gt;&lt;/strong&gt;, a thread-safe, persistent &lt;strong&gt;Red-Black Rope&lt;/strong&gt; text buffer built specifically for high-throughput text processing.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Logarithmic Edits:&lt;/strong&gt; Insertions, deletions, and range replacements execute in $O(\log N)$ time with structural sharing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Immutable Snapshots:&lt;/strong&gt; The UI and background analysis pipelines read from an immutable &lt;code&gt;TextSnapshot&lt;/code&gt; tied to a monotonic &lt;code&gt;TextRevision&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Delta Position Mapping:&lt;/strong&gt; When a batch edit occurs (for example, typing simultaneously with 15 active cursors), Kortex sorts operations in descending document order so earlier edits never invalidate the offsets of later ones. Once the rope commits the transaction, it returns a &lt;code&gt;TextEdit.Data&lt;/code&gt; delta, and &lt;code&gt;EditorEngine&lt;/code&gt; maps every surviving selection and caret through that delta mathematically:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;private fun mapSingleDelta(position: TextPosition, delta: TextEdit.Data.Single): TextPosition {
    val start = delta.startPosition
    val oldEnd = delta.oldEndPosition
    val newEnd = delta.newEndPosition

    return when {
        position &amp;lt; start -&amp;gt; position
        position in start..oldEnd -&amp;gt; newEnd
        else -&amp;gt; {
            val lineDelta = newEnd.line - oldEnd.line
            when (position.line) {
                oldEnd.line -&amp;gt; {
                    val colDelta = newEnd.column - oldEnd.column
                    TextPosition(line = position.line + lineDelta, column = position.column + colDelta)
                }
                else -&amp;gt; TextPosition(line = position.line + lineDelta, column = position.column)
            }
        }
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For structural line operations like &lt;code&gt;MoveLineUp&lt;/code&gt; and &lt;code&gt;MoveLineDown&lt;/code&gt; (&lt;code&gt;Alt + Up/Down&lt;/code&gt;), calculating position via delta projection alone would snap the caret to the edge of the replaced range. Kortex resolves this by supporting explicit &lt;strong&gt;&lt;code&gt;targetSelections&lt;/code&gt;&lt;/strong&gt; inside &lt;code&gt;EditorCommand.ApplyText&lt;/code&gt;, enabling line swaps to carry their carets and selections to adjacent lines without caret drift.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-killing-gc-pauses-with-bounded-native-lru-caches&quot;&gt;3. Killing GC Pauses with Bounded Native LRU Caches&lt;/h3&gt;
&lt;p&gt;Skia objects like &lt;code&gt;Paragraph&lt;/code&gt;, &lt;code&gt;TextLine&lt;/code&gt;, and &lt;code&gt;Paint&lt;/code&gt; wrap native C++ resources. Allocating and destroying them on every 16ms frame (or 8ms on 120Hz ProMotion displays) quickly overwhelms the memory allocator.&lt;/p&gt;
&lt;p&gt;Kortex solves this with a custom thread-safe &lt;code&gt;LruCache&lt;/code&gt; extending &lt;code&gt;CloseableResource&lt;/code&gt;. Whenever an item is evicted from the cache—or when the editor composable leaves the composition tree—its underlying native Skia pointer is deterministically closed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;internal class CodeParagraphCache(capacity: Int = 1000) : LruCache&amp;lt;CodeParagraphCache.Key, Paragraph&amp;gt;(capacity) {
    data class Key(
        val line: Int,
        val text: String,
        val tokens: List&amp;lt;Token&amp;gt;,
        val font: EditorFont,
        val syntaxTheme: SyntaxTheme,
    )

    override val factory: Key.() -&amp;gt; Paragraph = {
        // Builds and lays out the Skia Paragraph only on a cache miss
        ...
        builder.build().apply { layout(Float.MAX_VALUE) }
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Crucially, this zero-allocation philosophy extends to input gestures. Dragging the mouse to select text calls &lt;code&gt;getOffsetAtCoordinate&lt;/code&gt; on every pixel change. Re-parsing a Skia paragraph on each pointer move would create unacceptable jank. In Kortex, &lt;code&gt;EditorFont&lt;/code&gt; manages an internal &lt;code&gt;measurementParagraphCache&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;private val measurementParagraphCache = object : LruCache&amp;lt;String, Paragraph&amp;gt;(capacity = 128) {
    override val factory: String.() -&amp;gt; Paragraph = {
        val textToMeasure = this
        TextStyle().apply {
            this.fontFamilies = arrayOf(*Emails are not allowed*lyName)
            this.fontSize = *Emails are not allowed*
        }.use { baseTextStyle -&amp;gt;
            ParagraphStyle().apply {
                this.maxLinesCount = 1
                this.textStyle = baseTextStyle
            }.use { style -&amp;gt;
                buildParagraph(style) {
                    pushStyle(baseTextStyle)
                    addText(textToMeasure)
                    popStyle()
                }.use { builder -&amp;gt;
                    builder.build().apply { layout(Float.MAX_VALUE) }
                }
            }
        }
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;During continuous 120 FPS scrolling, cursor blinking, or rapid mouse selection, Kortex hits its bounded caches with near-100% hit rates, resulting in &lt;strong&gt;zero native allocations on the render loop&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-viewport-virtualization-amp-logarithmic-code-folding-foldmap-&quot;&gt;4. Viewport Virtualization &amp;amp; Logarithmic Code Folding (&lt;code&gt;FoldMap&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;How do you render a 200,000-line file when 50 different functions and classes are collapsed?&lt;/p&gt;
&lt;p&gt;Iterating through lines to check which ones are hidden would turn rendering into an $O(N)$ operation. Instead, Kortex compiles collapsed regions into an immutable &lt;strong&gt;&lt;code&gt;FoldMap&lt;/code&gt;&lt;/strong&gt;—a sorted, merged interval table storing cumulative hidden line counts and visual thresholds:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;internal data class Interval(
    val startLine: Int,
    val endLine: Int,
    val hiddenCount: Int,
    val cumulativeHiddenBefore: Int,
    val visualStartThreshold: Int,
)

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Converting between a &lt;strong&gt;Visual Line&lt;/strong&gt; (what the user sees on screen) and a &lt;strong&gt;Document Line&lt;/strong&gt; (the actual index in the Rope buffer) is a binary search over active intervals running in $O(\log K)$ time, where $K$ is the number of folded blocks:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;Viewport.of(...)&lt;/code&gt; calculates the exact visible visual line slice (&lt;code&gt;startVisualLine..endVisualLine&lt;/code&gt;) from &lt;code&gt;scrollY&lt;/code&gt; and &lt;code&gt;lineHeight&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Each visible line is mapped to its true document line via &lt;code&gt;foldMap.visualToDocumentLine(visualLineIndex)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Collapsed lines are completely culled before Skia ever touches them.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-pure-unidirectional-mvi-amp-smart-undo-redo-coalescing&quot;&gt;5. Pure Unidirectional MVI &amp;amp; Smart Undo/Redo Coalescing&lt;/h3&gt;
&lt;p&gt;UI components in Kortex never mutate state directly. The entire &lt;code&gt;:kortex-core&lt;/code&gt; module is completely headless (zero Compose dependencies) and operates as a deterministic state machine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;(EditorState, EditorAction) -&amp;gt; EditorResult(newState, EditorCommand?)

&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pure Reducers:&lt;/strong&gt; &lt;code&gt;CaretReducer&lt;/code&gt;, &lt;code&gt;SelectionReducer&lt;/code&gt;, &lt;code&gt;EditReducer&lt;/code&gt;, and &lt;code&gt;ViewportReducer&lt;/code&gt; compute next states purely in memory.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-Cursor Normalization:&lt;/strong&gt; Every time cursors move or expand, &lt;code&gt;Selection.normalize(...)&lt;/code&gt; sorts all active selections and automatically merges overlapping or touching ranges while preserving selection direction (&lt;code&gt;FORWARD&lt;/code&gt; vs. &lt;code&gt;BACKWARD&lt;/code&gt;), surrogate pairs (emoji), and &lt;code&gt;stickyColumn&lt;/code&gt; offsets.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sliding-Window Undo/Redo:&lt;/strong&gt; &lt;code&gt;HistoryManager&lt;/code&gt; records forward and inverse &lt;code&gt;TextOperation.Data&lt;/code&gt; commands and automatically merges contiguous single-line insertions or deletions if they occur within a &lt;strong&gt;700ms&lt;/strong&gt; window—restoring exact multi-cursor positions when undone.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-full-ide-tooling-async-analysis-squiggles-amp-hierarchical-themes&quot;&gt;6. Full IDE Tooling: Async Analysis, Squiggles &amp;amp; Hierarchical Themes&lt;/h3&gt;
&lt;p&gt;On top of the plain-text &lt;code&gt;:kortex-core&lt;/code&gt; engine sits &lt;code&gt;:kortex-code&lt;/code&gt; and &lt;code&gt;:kortex-code-compose&lt;/code&gt;, turning the editor into a complete IDE component.&lt;/p&gt;
&lt;h4 id=&quot;debounced-coroutine-pipelines&quot;&gt;Debounced Coroutine Pipelines&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;CodeEditorEngine&lt;/code&gt; binds to a pluggable &lt;code&gt;LanguageAnalysisService&lt;/code&gt; using Kotlin Coroutine Flows on &lt;code&gt;Dispatchers.Default&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tokenization (&lt;code&gt;TokenProvider&lt;/code&gt;):&lt;/strong&gt; Debounced at &lt;strong&gt;50ms&lt;/strong&gt;, dropping stale snapshots via &lt;code&gt;distinctUntilChangedBy(TextSnapshot::revision)&lt;/code&gt; and &lt;code&gt;mapLatest&lt;/code&gt;. Existing folding regions and symbol occurrences are preserved during token updates.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Diagnostics (&lt;code&gt;DiagnosticsProvider&lt;/code&gt;):&lt;/strong&gt; Debounced at &lt;strong&gt;300ms&lt;/strong&gt;, feeding &lt;code&gt;DiagnosticsLayer&lt;/code&gt; to draw procedural sine-wave/zigzag paths directly under erroneous tokens.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hover Documentation (&lt;code&gt;DocumentationProvider&lt;/code&gt;):&lt;/strong&gt; Debounced at &lt;strong&gt;250ms&lt;/strong&gt;, resolving documentation for the symbol under the primary caret and updating &lt;code&gt;CodeEditorState.documentation&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IntelliSense (&lt;code&gt;CompletionProvider&lt;/code&gt;):&lt;/strong&gt; Floating popup anchored to the active caret's visual coordinates with keyboard navigation (&lt;code&gt;Up&lt;/code&gt;, &lt;code&gt;Down&lt;/code&gt;, &lt;code&gt;Tab&lt;/code&gt;/&lt;code&gt;Enter&lt;/code&gt;, &lt;code&gt;Escape&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;hierarchical-token-theming&quot;&gt;Hierarchical Token Theming&lt;/h4&gt;
&lt;p&gt;Inspired by TextMate and VS Code scopes, &lt;code&gt;SyntaxTheme&lt;/code&gt; resolves token colors hierarchically using dot-separated fallbacks. If your lexer emits &lt;code&gt;keyword.control&lt;/code&gt; and the active theme only defines a color for &lt;code&gt;keyword&lt;/code&gt;, Kortex walks up the scope tree automatically:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;val syntaxTheme = SyntaxTheme.build(defaultText = Color(0xFFABB2BF)) {
    put(TokenType.KEYWORD.name, Color(0xFFC678DD))
    put(TokenType.KEYWORD_CONTROL.name, Color(0xFFE06C75)) // Overrides &quot;keyword&quot;
    put(TokenType.FUNCTION.name, Color(0xFF61AFEF))
    put(TokenType.STRING.name, Color(0xFF98C379))
}

&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;getting-started-in-30-seconds&quot;&gt;Getting Started in 30 Seconds&lt;/h3&gt;
&lt;p&gt;Kortex is published on &lt;strong&gt;Maven Central&lt;/strong&gt; and divided into four modular artifacts so you only pull what you need:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;dependencies {
    implementation(&quot;io.github.numq.kortex:core:1.1.0&quot;)         // Headless engine
    implementation(&quot;io.github.numq.kortex:core-compose:1.1.0&quot;) // Plain text Skia editor
    implementation(&quot;io.github.numq.kortex:code:1.1.0&quot;)         // Code analysis engine
    implementation(&quot;io.github.numq.kortex:code-compose:1.1.0&quot;) // Full IDE Composable
    implementation(&quot;io.github.numq.krope:text:1.1.1&quot;)          // Rope text buffer
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Embedding a full-featured code editor in your Compose Multiplatform app takes just a few lines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@Composable
fun IdeEditorScreen() {
    val scope = rememberCoroutineScope()

    val buffer = remember {
        RopeTextBuffer(
            initialText = &quot;fun main() {\n    println(\&quot;Hello, Kortex!\&quot;)\n}&quot;,
            initialLineEnding = TextLineEnding.LF,
            initialEncoding = Encoding.UTF8,
            enablePooling = true
        )
    }

    val editorEngine = remember(buffer) { EditorEngine(scope = scope, buffer = buffer) }
    val codeEngine = remember(editorEngine) {
        CodeEditorEngine(
            scope = scope,
            engine = editorEngine,
            analysisService = myLanguageAnalysisService
        )
    }

    val font = remember {
        val typeface = FontMgr.default.matchFamilyStyle(&quot;JetBrains Mono&quot;, FontStyle.NORMAL)
            ?: Typeface.makeEmpty()
        EditorFont(typeface = typeface, size = 14f, lineSpacing = 1.25f)
    }

    DisposableEffect(font) {
        onDispose {
            font.close()
        }
    }

    val theme = rememberCodeEditorTheme()

    KortexCodeEditor(
        engine = codeEngine,
        font = font,
        theme = theme,
        columnRuler = 80,
        modifier = Modifier.fillMaxSize()
    )
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Out of the box, you get &lt;code&gt;Alt + Click&lt;/code&gt; multi-cursors, &lt;code&gt;Ctrl/Cmd + D&lt;/code&gt; next-occurrence selection, code folding, smart indentation, line moving (&lt;code&gt;Alt + Up/Down&lt;/code&gt;), comment toggling (&lt;code&gt;Ctrl/Cmd + /&lt;/code&gt;), right-click context menus, and native desktop scrollbar adapters.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;explore-the-source-code&quot;&gt;Explore the Source Code&lt;/h3&gt;
&lt;p&gt;Both &lt;strong&gt;Kortex&lt;/strong&gt; and &lt;strong&gt;Krope&lt;/strong&gt; are 100% open-source under the Apache 2.0 License. Whether you are building a developer tool, a Markdown note-taking app, a database query console, or a full multiplatform IDE, I would love for you to try it out and share your feedback!&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Kortex:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/numq/kortex&quot;&gt;github.com/numq/kortex&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Krope:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/numq/krope&quot;&gt;github.com/numq/krope&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31745/why-basictextfield-fails-at-scale-and-how-i-buildkortex</guid>
<pubDate>Mon, 19 Oct 2026 14:55:34 +0000</pubDate>
</item>
<item>
<title>Kempo: Real-Time Audio Time-Stretching, Pitch-Shifting, and Formant Control in Pure KMP</title>
<link>https://coderlegion.com/31021/kempo-real-time-audio-time-stretching-pitch-shifting-and-formant-control-in-pure-kmp</link>
<description>&lt;h4 id=&quot;zero-allocation-streaming-phase-locked-clarity-and-multichannel-spatial-coherence-across-jvm-android-ios-and-webassembly-&quot;&gt;Zero-allocation streaming, phase-locked clarity, and multichannel spatial coherence across JVM, Android, iOS, and WebAssembly.&lt;/h4&gt;
&lt;p&gt;Whether you are building a podcast player with variable playback speed, a music practice app that slows down a complex guitar solo without dropping the tuning, a real-time voice modulator, or a spatial audio engine for a game, you eventually hit the same fundamental challenge: &lt;strong&gt;decoupling time from pitch&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In raw digital audio, playback speed and pitch are locked together. Play a buffer twice as fast, and the pitch jumps up a full octave. Slow it down, and everything drags into a muddy baritone.&lt;/p&gt;
&lt;p&gt;Breaking that link—while keeping drums punchy, vocals natural, and the stereo image intact—requires a dedicated Digital Signal Processing (DSP) engine.&lt;/p&gt;
&lt;p&gt;Meet &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/numq/kempo&quot;&gt;Kempo&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;io.github.numq:kempo&lt;/code&gt;): an open-source, pure Kotlin Multiplatform audio time-stretching and pitch-shifting library built for studio-grade acoustic transparency and sub-millisecond real-time streaming across &lt;strong&gt;JVM, Android, iOS, and WebAssembly (Wasm/JS)&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-kempo-brings-to-your-audio-stack&quot;&gt;What Kempo Brings to Your Audio Stack&lt;/h3&gt;
&lt;p&gt;Kempo is designed to be drop-in simple for high-level application code while exposing full, granular control for low-level audio threads:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Independent Time, Pitch, and Formant Control:&lt;/strong&gt; Speed up or slow down audio (&lt;code&gt;0.25x&lt;/code&gt; to &lt;code&gt;3.0x&lt;/code&gt; and beyond), transpose musical pitch in semitones, and shift vocal timbre independently.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zero-Allocation Real-Time Streaming:&lt;/strong&gt; Once configured, the block-by-block processing loop allocates &lt;strong&gt;0 bytes on the heap&lt;/strong&gt;, eliminating Garbage Collection pauses on high-priority audio threads.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vertical Harmonic Phase-Locking:&lt;/strong&gt; Keeps transients crisp and eliminates the hollow, &quot;underwater&quot; phasing artifacts common in basic time-stretchers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multichannel &amp;amp; 5.1 Surround Coherence:&lt;/strong&gt; Preserves exact inter-channel phase relationships across stereo and surround tracks so the soundstage never collapses.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sample-Accurate Offline Rendering:&lt;/strong&gt; Built-in pre-roll warm-up, algorithmic latency compensation, and automatic overlap-add tail flushing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Built-In PCM-16 Serialization:&lt;/strong&gt; Convert raw little-endian or big-endian WAV/PCM byte arrays directly into normalized floating-point tracks and back.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-kempo-keeps-audio-crisp-without-the-artifacts-&quot;&gt;How Kempo Keeps Audio Crisp (Without the Artifacts)&lt;/h3&gt;
&lt;p&gt;If you have ever used a naive time-stretching algorithm, you probably recognize three classic audio defects: smeared drum hits, &quot;chipmunk&quot; vocals, and a collapsing stereo field. Kempo solves all three at the engine level.&lt;/p&gt;
&lt;h4 id=&quot;1-eliminating-transient-smearing-with-vertical-phase-locking&quot;&gt;1. Eliminating Transient Smearing with Vertical Phase-Locking&lt;/h4&gt;
&lt;p&gt;Standard phase vocoders split audio into hundreds of narrow frequency bins and advance the phase of each bin over time. Because each bin is updated in isolation, the natural harmonic alignment between a fundamental note and its overtones quickly drifts apart. Percussive attacks lose their snap, and instruments sound phasey and diffuse.&lt;/p&gt;
&lt;p&gt;Kempo continuously analyzes the smoothed spectral energy contour of each frame to locate prominent spectral peaks. Instead of letting neighboring bins drift, Kempo performs &lt;strong&gt;vertical phase-locking&lt;/strong&gt;—anchoring the phase trajectories of surrounding frequency bins to their dominant harmonic peaks. Harmonics move together as a unified acoustic event, keeping transients tight.&lt;/p&gt;
&lt;p&gt;When stretching audio to extreme lengths (beyond &lt;code&gt;2.0x&lt;/code&gt;), Kempo automatically transitions into controlled stochastic phase dispersion, preventing the metallic ringing that usually plagues ultra-slow playback.&lt;/p&gt;
&lt;h4 id=&quot;2-fixing-the-chipmunk-effect-with-formant-decoupling&quot;&gt;2. Fixing the &quot;Chipmunk&quot; Effect with Formant Decoupling&lt;/h4&gt;
&lt;p&gt;When you pitch a human voice up by +5 semitones, you aren't just raising the pitch of the vocal cords—you are also shrinking the perceived size of the singer's vocal tract. The result is the notorious &quot;chipmunk&quot; effect (or the &quot;monster&quot; effect when pitching down).&lt;/p&gt;
&lt;p&gt;In Kempo, pitch and vocal timbre (formants) are decoupled. Kempo extracts the spectral envelope of the signal and allows you to shift or lock formants independently of the musical transposition:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Natural Pitch Shifting:&lt;/strong&gt; Enable formant compensation (&lt;code&gt;compensatePitch = true&lt;/code&gt;) to keep a singer's vocal tract resonance anchored while changing their melody.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Creative Voice Design:&lt;/strong&gt; Shift formants up or down while keeping pitch at &lt;code&gt;0 st&lt;/code&gt; to transform the character and age of a voice without changing the note being sung.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;3-preserving-stereo-width-and-5-1-surround-imaging&quot;&gt;3. Preserving Stereo Width and 5.1 Surround Imaging&lt;/h4&gt;
&lt;p&gt;Our ears localize sound in space by detecting tiny timing and phase differences between the left and right channels. If an audio processor stretches the Left and Right channels independently, their relative phases drift apart—causing the stereo image to wobble and center-panned vocals to hollow out when downmixed.&lt;/p&gt;
&lt;p&gt;Kempo processes multichannel audio coherently. At every frequency band, it identifies the dominant channel and locks the Inter-channel Phase Differences (IPD) of all remaining channels to that reference. Whether you are processing a stereo master or a discrete 6-channel 5.1 surround mix, panning stays locked and center dialogue remains phase-aligned.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;getting-started&quot;&gt;Getting Started&lt;/h3&gt;
&lt;p&gt;Kempo is published on Maven Central. Add the dependency to your &lt;code&gt;build.gradle.kts&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(&quot;io.github.numq:kempo:1.0.0&quot;)
        }
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or for a single-platform JVM/Android module:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;dependencies {
    implementation(&quot;io.github.numq:kempo:1.0.0&quot;)
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;1-the-one-liner-offline-track-processing&quot;&gt;1. The One-Liner: Offline Track Processing&lt;/h4&gt;
&lt;p&gt;If you have raw 16-bit PCM bytes (from a WAV file, network stream, or microphone recording) and want to stretch or transpose them in a background coroutine, &lt;code&gt;PcmConverter&lt;/code&gt; and &lt;code&gt;AudioTrack&lt;/code&gt; handle the entire pipeline in a few lines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;import io.github.numq.kempo.PcmConverter

// 1. Decode interleaved 16-bit PCM bytes into a normalized [-1.0f, 1.0f] AudioTrack
val track = PcmConverter.fromPcm16(
    bytes = rawWavBytes,
    channels = 2,
    sampleRate = 44100f,
    isLittleEndian = true
)

// 2. Speed up by 25%, pitch up by 3 semitones, keep natural formants
val stretchedTrack = track.stretch(
    timeRatio = 1.25f,
    pitchSemitones = 3.0f,
    formantSemitones = 0.0f
)

// 3. Convert back to 16-bit PCM bytes for playback or file export
val outputBytes = PcmConverter.toPcm16(stretchedTrack, isLittleEndian = true)

&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;2-zero-allocation-real-time-streaming&quot;&gt;2. Zero-Allocation Real-Time Streaming&lt;/h4&gt;
&lt;p&gt;Real-time audio callbacks operate under strict deadlines. Allocating arrays or objects inside the render loop will eventually trigger the garbage collector and cause audible clicks.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;KempoStretch&lt;/code&gt; pre-allocates all ring buffers, FFT plans, twiddle factors, and spectral workspaces upfront during &lt;code&gt;presetDefault()&lt;/code&gt; or &lt;code&gt;configure()&lt;/code&gt;. Inside your audio callback, &lt;code&gt;process()&lt;/code&gt; mutates pre-allocated buffers in place:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;import io.github.numq.kempo.KempoStretch
import kotlin.math.max

class LiveAudioProcessor(
    channels: Int = 2,
    sampleRate: Float = 48000f
) {
    private val stretch = KempoStretch().apply {
        presetDefault(channels, sampleRate)
    }

    val outBlockSize: Int = stretch.defaultInterval
    private val outBuffer = Array(channels) { FloatArray(outBlockSize) }

    fun updateParameters(pitchSemitones: Float, formantSemitones: Float) {
        stretch.setTransposeSemitones(pitchSemitones)
        stretch.setFormantSemitones(formantSemitones, compensatePitch = true)
    }

    // Safe for high-priority audio threads: 0 B heap allocations
    fun processFrame(
        inputChannels: Array&amp;lt;FloatArray&amp;gt;,
        playbackSpeed: Float
    ): Array&amp;lt;FloatArray&amp;gt; {
        val inSamples = max(1, (outBlockSize * playbackSpeed).toInt())
        stretch.process(
            inputs = inputChannels,
            inputSamples = inSamples,
            outputs = outBuffer,
            outputSamples = outBlockSize
        )
        return outBuffer
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Need to support interactive scrubbing on a timeline without clicks or phase resets? Use &lt;code&gt;stretch.seek()&lt;/code&gt; before resuming playback to pre-seed the internal phase accumulators with the audio frames right before the new cursor position:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;val seekPreRoll = minOf(stretch.seekLength(), targetSampleIndex)
stretch.seek(
    inputs = track.channels,
    inputSamples = seekPreRoll,
    playbackRate = 1.0,
    inputOffset = (targetSampleIndex - seekPreRoll).coerceAtLeast(0)
)

&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;3-sample-accurate-offline-rendering-with-exact-&quot;&gt;3. Sample-Accurate Offline Rendering with &lt;code&gt;exact()&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;When rendering an audio clip for a DAW timeline or a game asset pipeline, you often need the output buffer to match an exact target sample count—without leading silence from the STFT window latency or cut-off reverb tails at the end.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;KempoStretch.exact()&lt;/code&gt; automates the entire offline lifecycle: it calculates the required pre-roll (&lt;code&gt;outputSeekLength&lt;/code&gt;), warms up the vocoder state (&lt;code&gt;outputSeek&lt;/code&gt;), processes the body of the track, and flushes the final synthesis tails (&lt;code&gt;flush&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;import io.github.numq.kempo.KempoStretch
import kotlin.math.roundToInt

fun renderExactClip(
    input: Array&amp;lt;FloatArray&amp;gt;,
    sampleRate: Float,
    timeRatio: Float,
    pitchSemitones: Float
): Array&amp;lt;FloatArray&amp;gt; {
    val channels = input.size
    val inSamples = input[0].size
    val outSamples = (inSamples / timeRatio).roundToInt().coerceAtLeast(1)
    val output = Array(channels) { FloatArray(outSamples) }

    val stretch = KempoStretch().apply {
        presetDefault(channels, sampleRate)
        setTransposeSemitones(pitchSemitones)
    }

    stretch.exact(
        inputs = input,
        inputSamples = inSamples,
        outputs = output,
        outputSamples = outSamples
    )
    return output
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;4-custom-frequency-mapping-amp-tonality-limits&quot;&gt;4. Custom Frequency Mapping &amp;amp; Tonality Limits&lt;/h4&gt;
&lt;p&gt;For experimental sound design, spectral effects, or specialized speech conditioning, Kempo lets you bypass standard semitone math entirely:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tonality Limits:&lt;/strong&gt; When transposing speech or vocals up, multiplying high-frequency sibilance (&quot;s&quot; and &quot;sh&quot; sounds) can make them sound lispy or artificial. Passing a &lt;code&gt;tonalityLimit&lt;/code&gt; to &lt;code&gt;setTransposeSemitones(semitones, tonalityLimit)&lt;/code&gt; transitions the upper spectrum from multiplicative harmonic scaling to a clean linear shift.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Custom Transfer Functions:&lt;/strong&gt; Pass any lambda to &lt;code&gt;setFreqMap&lt;/code&gt; to warp normalized frequencies &lt;code&gt;[0.0f, 0.5f]&lt;/code&gt; non-linearly:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;stretch.setFreqMap { normalizedFreq -&amp;gt;
    // Custom non-linear spectral warping
    (normalizedFreq * 1.15f).coerceIn(0f, 0.5f)
}

&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;engineered-for-speed-in-pure-kotlin&quot;&gt;Engineered for Speed in Pure Kotlin&lt;/h3&gt;
&lt;p&gt;Writing high-throughput DSP in pure Kotlin without falling back to platform-specific C/C++ libraries requires careful attention to memory layout and CPU cache locality:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Flat Power-of-Two Ring Buffers (&lt;code&gt;MultiChannelBuffer&lt;/code&gt;):&lt;/strong&gt; Instead of nested object arrays or modulo arithmetic, Kempo backs all channels with a single flat &lt;code&gt;FloatArray&lt;/code&gt;. Wrap-around indices are resolved via bitwise masks (&lt;code&gt;(head + offset) and mask&lt;/code&gt;), and overlap-add accumulation uses 4x unrolled loops.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;In-Place Mixed-Radix FFT (&lt;code&gt;FastFft&lt;/code&gt; &amp;amp; &lt;code&gt;ModifiedRealFFT&lt;/code&gt;):&lt;/strong&gt; Supports radix-2, radix-3, radix-4, and composite window sizes with precomputed twiddle tables. Real signals are evaluated on a half-bin shifted grid &lt;code&gt;(k + 0.5) / N&lt;/code&gt;, avoiding DC and Nyquist edge singularities and cutting transform overhead in half.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Split Computation Mode (&lt;code&gt;split = true&lt;/code&gt;):&lt;/strong&gt; In real-time audio, doing a full STFT analysis and synthesis every &lt;code&gt;N&lt;/code&gt; samples can cause periodic CPU spikes. Enabling &lt;code&gt;split = true&lt;/code&gt; in &lt;code&gt;configure()&lt;/code&gt; or &lt;code&gt;presetCheaper()&lt;/code&gt; slices the spectral workload into small incremental steps distributed evenly across every sample frame.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automatic Silence Bypass:&lt;/strong&gt; When input energy drops below the noise floor, Kempo automatically bypasses heavy spectral math while keeping ring buffers synchronized.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In JMH benchmarks on standard desktop JVM hardware (44.1 kHz audio), Kempo processes a real-time streaming block in &lt;strong&gt;~220 μs (mono)&lt;/strong&gt; and &lt;strong&gt;~484 μs (stereo)&lt;/strong&gt;, and renders offline audio at &lt;strong&gt;over 128x real-time speed&lt;/strong&gt; for mono and &lt;strong&gt;~70x real-time&lt;/strong&gt; for stereo.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;try-it-live-the-compose-desktop-studio-app&quot;&gt;Try It Live: The Compose Desktop Studio App&lt;/h3&gt;
&lt;p&gt;Want to hear how Kempo handles your own audio files before writing a line of code?&lt;/p&gt;
&lt;p&gt;The repository includes an interactive &lt;strong&gt;Compose Multiplatform Desktop Studio&lt;/strong&gt; application (&lt;code&gt;:example&lt;/code&gt;). Drop any &lt;code&gt;.wav&lt;/code&gt; file onto the window to inspect its waveform, scrub through the timeline in real time, and tweak live parameters on the fly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Playback Speed (&lt;code&gt;0.25x&lt;/code&gt; – &lt;code&gt;3.00x&lt;/code&gt;)&lt;/strong&gt; with instant presets (&lt;code&gt;0.50x&lt;/code&gt;, &lt;code&gt;0.75x&lt;/code&gt;, &lt;code&gt;1.25x&lt;/code&gt;, &lt;code&gt;1.50x&lt;/code&gt;, &lt;code&gt;2.00x&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pitch Shift (&lt;code&gt;-12 st&lt;/code&gt; to &lt;code&gt;+12 st&lt;/code&gt;)&lt;/strong&gt; in semitone steps&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Formant Shift (&lt;code&gt;-12 st&lt;/code&gt; to &lt;code&gt;+12 st&lt;/code&gt;)&lt;/strong&gt; for real-time vocal timbre shaping&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;explore-the-code&quot;&gt;Explore the Code&lt;/h3&gt;
&lt;p&gt;Check out the full source code, architecture docs, and the runnable CLI attack simulation on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/numq/kempo&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31021/kempo-real-time-audio-time-stretching-pitch-shifting-and-formant-control-in-pure-kmp</guid>
<pubDate>Wed, 14 Oct 2026 07:01:23 +0000</pubDate>
</item>
<item>
<title>Why HTTPS Won’t Save Your Checkout Flow: Building Zero-Trust State Machines in Kotlin Multiplatform</title>
<link>https://coderlegion.com/31019/https-wont-save-your-checkout-flow-building-zero-trust-state-machines-kotlin-multiplatform</link>
<description>&lt;h4 id=&quot;how-to-protect-multi-step-workflows-fintech-funnels-and-server-driven-ui-from-step-skipping-state-tampering-and-replay-attacks-with-zero-runtime-dependencies-&quot;&gt;How to protect multi-step workflows, fintech funnels, and Server-Driven UI from step-skipping, state tampering, and replay attacks — with zero runtime dependencies.&lt;/h4&gt;
&lt;p&gt;Imagine a standard e-commerce checkout flow: &lt;strong&gt;Shopping Cart → Shipping Address → Promo Code → Payment Authorization&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;You use HTTPS. You validate JWT tokens on every request. Your backend endpoints are covered with unit tests. You feel safe.&lt;/p&gt;
&lt;p&gt;Then someone opens your app on a rooted device, hooks into memory with Frida, or simply manipulates an internal deep link. Instead of going from &lt;strong&gt;Cart&lt;/strong&gt; to &lt;strong&gt;Shipping&lt;/strong&gt; (where a $50 express delivery fee is calculated), the client dispatches a transition straight from &lt;strong&gt;Cart&lt;/strong&gt; to &lt;strong&gt;Payment&lt;/strong&gt;. Or worse, in a Server-Driven UI (SDUI) flow, an attacker replays a cached JSON payload from five minutes ago where a KYC verification flag was temporarily set to &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;HTTPS only encrypts the pipe. JWTs only verify who is holding the token.&lt;/strong&gt; Neither guarantees that your client-side state machine actually followed the legal sequence of steps to reach its current screen.&lt;/p&gt;
&lt;p&gt;To fix this at the architectural level without littering business logic with endless &lt;code&gt;if-else&lt;/code&gt; checks, I built &lt;strong&gt;TrustChain&lt;/strong&gt; — a pure functional, cryptographically verifiable state machine ledger for &lt;strong&gt;Kotlin Multiplatform (KMP)&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-mental-model-think-git-commits-for-your-app-state&quot;&gt;The Mental Model: Think &quot;Git Commits&quot; for Your App State&lt;/h3&gt;
&lt;p&gt;You don’t need a PhD in cryptography or heavy blockchain consensus algorithms to secure a user journey. You just need &lt;strong&gt;strict lineage&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Think about how Git works: every commit contains a hash of its own content plus the exact hash of the parent commit. If you try to alter history or insert a commit out of nowhere, the hashes don't match, and Git rejects the tree immediately.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TrustChain brings this exact mechanism to your application’s navigation and state stack:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Every screen or workflow step has a unique &lt;code&gt;hash&lt;/code&gt; representing its current state, and a &lt;code&gt;previousHash&lt;/code&gt; pointing to the exact step that preceded it.&lt;/li&gt;
&lt;li&gt;When the user moves forward, the incoming step’s &lt;code&gt;previousHash&lt;/code&gt; &lt;strong&gt;must&lt;/strong&gt; match the &lt;code&gt;hash&lt;/code&gt; of the currently active step on top of the stack.&lt;/li&gt;
&lt;li&gt;Partial state updates (like applying a promo code or updating live order status over WebSockets) arrive as &lt;strong&gt;signed deltas&lt;/strong&gt; (&lt;code&gt;DeltaBlock&lt;/code&gt;). They verify the current hash, check the server’s asymmetric digital signature, mutate the state, and verify the new hash.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If an attacker tries to skip a screen, inject a fake discount, or replay an old state, the hash chain breaks. The state machine fails fast before a single pixel is rendered or a network call is fired.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Security Concern &lt;/th&gt;&lt;th&gt;Standard REST + HTTPS &lt;/th&gt;&lt;th&gt;JWT Tokens &lt;/th&gt;&lt;th&gt;TrustChain&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Data In-Transit Encryption&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Yes (TLS) &lt;/td&gt;&lt;td&gt;Yes (TLS) &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Yes (TLS)&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Prevent Step Skipping (Deep Links)&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;No (Complex backend checks) &lt;/td&gt;&lt;td&gt;No &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Yes (Parent Hash Check)&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Detect Tampered State Deltas&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;No &lt;/td&gt;&lt;td&gt;Partially &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Yes (Asymmetric Signatures)&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Replay Attack Prevention&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Manual nonces required &lt;/td&gt;&lt;td&gt;No &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Yes (Strict Hash Lineage)&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Zero Runtime Dependencies&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;N/A &lt;/td&gt;&lt;td&gt;Often heavy &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Yes (Pure KMP)&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;designing-for-zero-bloat-the-core-primitives&quot;&gt;Designing for Zero Bloat: The Core Primitives&lt;/h3&gt;
&lt;p&gt;One of my main goals with &lt;code&gt;TrustChain&lt;/code&gt; was keeping the core completely free of third-party runtime dependencies so it drops cleanly into Android, iOS, Desktop, Web, or backend KMP targets.&lt;/p&gt;
&lt;h4 id=&quot;1-verifiableblock&quot;&gt;1. &lt;code&gt;VerifiableBlock&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Any screen, workflow step, or state snapshot in your domain simply implements a three-property interface:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;interface VerifiableBlock {
    val id: String
    val hash: String
    val previousHash: String? // null only for the root (genesis) step
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;2-deltablock-signed-partial-updates-&quot;&gt;2. &lt;code&gt;DeltaBlock&lt;/code&gt; (Signed Partial Updates)&lt;/h4&gt;
&lt;p&gt;Why re-fetch an entire screen when a user applies a 10% coupon or an order changes status? Instead of sending full state trees, the server sends a lightweight &lt;code&gt;DeltaBlock&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@Serializable
data class DeltaBlock&amp;lt;P&amp;gt;(
    @SerialName(&quot;patches&quot;) val patches: P,
    @SerialName(&quot;previous_hash&quot;) val previousHash: String,
    @SerialName(&quot;new_hash&quot;) val newHash: String,
    @SerialName(&quot;signature&quot;) val signature: String = &quot;&quot;
)

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice how &lt;code&gt;DeltaBlock&lt;/code&gt; locks the mutation from both sides:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It can &lt;strong&gt;only&lt;/strong&gt; be applied if the client is currently sitting on &lt;code&gt;previousHash&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Its &lt;code&gt;newHash&lt;/code&gt; is signed by the server's private key (&lt;code&gt;RSA&lt;/code&gt;, &lt;code&gt;Ed25519&lt;/code&gt;, or &lt;code&gt;ECDSA&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Once the client applies &lt;code&gt;patches&lt;/code&gt; via a &lt;code&gt;BlockPatcher&lt;/code&gt;, the resulting block must produce &lt;code&gt;newHash&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;3-pure-functional-reduction&quot;&gt;3. Pure Functional Reduction&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;TrustChain&lt;/code&gt; is completely immutable. There are no hidden side effects, mutable singletons, or uncaught exceptions flying across coroutines. You send a &lt;code&gt;ChainEvent&lt;/code&gt; (&lt;code&gt;Push&lt;/code&gt;, &lt;code&gt;Pop&lt;/code&gt;, &lt;code&gt;Replace&lt;/code&gt;, or &lt;code&gt;ApplyDeltas&lt;/code&gt;), and &lt;code&gt;reduce()&lt;/code&gt; returns a standard Kotlin &lt;code&gt;Result&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;sealed interface ChainEvent&amp;lt;out B : VerifiableBlock, out P&amp;gt; {
    data class Push&amp;lt;B : VerifiableBlock&amp;gt;(val block: B) : ChainEvent&amp;lt;B, Nothing&amp;gt;
    data object Pop : ChainEvent&amp;lt;Nothing, Nothing&amp;gt;
    data class Replace&amp;lt;B : VerifiableBlock&amp;gt;(val block: B) : ChainEvent&amp;lt;B, Nothing&amp;gt;
    data class ApplyDeltas&amp;lt;P&amp;gt;(val blocks: List&amp;lt;DeltaBlock&amp;lt;P&amp;gt;&amp;gt;) : ChainEvent&amp;lt;Nothing, P&amp;gt;
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If a batch of 5 deltas is applied and the 4th delta has a broken hash link or invalid signature, &lt;strong&gt;atomicity is preserved&lt;/strong&gt; — the entire reduction fails cleanly with a typed &lt;code&gt;ChainError&lt;/code&gt;, leaving your original &lt;code&gt;TrustChain&lt;/code&gt; untouched.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;real-world-walkthrough-the-unbreakable-checkout&quot;&gt;Real-World Walkthrough: The Unbreakable Checkout&lt;/h3&gt;
&lt;p&gt;Let’s look at how this works in practice using a multi-step checkout flow.&lt;/p&gt;
&lt;p&gt;First, we define our domain step:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@Serializable
data class CheckoutStep(
    override val id: String,
    val title: String,
    val state: Map&amp;lt;String, String&amp;gt; = emptyMap(),
    override val hash: String,
    override val previousHash: String? = null
) : VerifiableBlock

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, we initialize &lt;code&gt;TrustChain&lt;/code&gt; on the client. Notice how cryptography and state patching are injected via simple functional interfaces (&lt;code&gt;SignatureVerifier&lt;/code&gt; and &lt;code&gt;BlockPatcher&lt;/code&gt;). You aren't locked into any specific crypto library:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;var clientChain = TrustChain&amp;lt;CheckoutStep, Map&amp;lt;String, String&amp;gt;&amp;gt;(
    strictMode = true,
    verifier = SignatureVerifier { payload, signatureBase64 -&amp;gt;
        // Plug in your platform's Ed25519 / RSA / ECDSA verifier
        CryptoEngine.verify(serverPublicKey, payload, signatureBase64)
    },
    patcher = { step, patches, newHash -&amp;gt;
        step.copy(
            state = step.state + patches,
            hash = newHash
        )
    }
)

&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-legitimate-user-journey&quot;&gt;The Legitimate User Journey&lt;/h4&gt;
&lt;p&gt;As the user progresses through the checkout, every step returned by the server references the exact hash of the previous state:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// 1. User opens the Shopping Cart (Genesis block, previousHash = null)
val cartStep = server.startCheckout(sessionId)
clientChain = clientChain.reduce(ChainEvent.Push(cartStep)).getOrThrow()
// Active: Shopping Cart | Hash: h_c2c1a28598b8ad69

// 2. User confirms Shipping Address (Chained to Cart's hash)
val shippingStep = server.submitShipping(sessionId, &quot;Baker St 221B&quot;)
clientChain = clientChain.reduce(ChainEvent.Push(shippingStep)).getOrThrow()
// Active: Shipping &amp;amp; Address | Hash: h_50bda3ba68cced0d | Prev: h_c2c1a28598b8ad69

// 3. User applies a 10% Promo Code (Signed DeltaBlock mutation)
val promoDelta = server.applyPromoCode(sessionId, &quot;10%&quot;)
clientChain = clientChain.reduce(ChainEvent.ApplyDeltas(listOf(promoDelta))).getOrThrow()
// Active: Shipping &amp;amp; Address (Patched) | New Total: $1890 | New Hash: h_6471c5f0c53a644b

// 4. User proceeds to Payment (Chained to the patched Shipping hash)
val paymentStep = server.proceedToPayment(sessionId)
clientChain = clientChain.reduce(ChainEvent.Push(paymentStep)).getOrThrow()
// Active: Payment Authorization | Hash: h_6a6fd155e392f7ef

&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;simulating-the-attack-skipping-shipping&quot;&gt;Simulating the Attack: Skipping Shipping&lt;/h4&gt;
&lt;p&gt;Now, let’s see what happens when an attacker loads the Cart (&lt;code&gt;h_c2c1a28598b8ad69&lt;/code&gt;) and tries to jump straight to the &lt;code&gt;paymentStep&lt;/code&gt; (bypassing shipping calculation):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;var attackedChain = TrustChain&amp;lt;CheckoutStep, Map&amp;lt;String, String&amp;gt;&amp;gt;(strictMode = true)
attackedChain = attackedChain.reduce(ChainEvent.Push(cartStep)).getOrThrow()

// Attacker tries to force-push the Payment step directly onto the Cart
val attackResult = attackedChain.reduce(ChainEvent.Push(paymentStep))

attackResult.fold(
    onSuccess = {
        println(&quot;VULNERABILITY: Attack succeeded!&quot;)
    },
    onFailure = { error -&amp;gt;
        when (error) {
            is ChainError.HashMismatch -&amp;gt; {
                println(&quot;[!] BLOCKED BY TRUSTCHAIN: State machine integrity preserved.&quot;)
                println(&quot;    Expected parent: ${error.expected}, Actual: ${error.actual}&quot;)
            }
            is ChainError.SignatureVerificationFailed -&amp;gt; {
                println(&quot;[!] BLOCKED: Invalid server signature for block ${error.blockHash}&quot;)
            }
            else -&amp;gt; println(&quot;[!] Blocked: ${error.message}&quot;)
        }
    }
)

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Console Output:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;------------------------------------------------------
  Simulating Attack: Attacker tries to skip shipping  
------------------------------------------------------
Attacker has Cart loaded.
Attacker attempts to directly apply Payment step (bypassing Shipping)...
[!] BLOCKED BY TRUSTCHAIN: State machine integrity preserved.
Expected parent: h_c2c1a28598b8ad69, Actual: h_6471c5f0c53a644b

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because &lt;code&gt;paymentStep&lt;/code&gt; expects its parent to be the patched shipping step (&lt;code&gt;h_6471c5f0c53a644b&lt;/code&gt;), pushing it onto the cart (&lt;code&gt;h_c2c1a28598b8ad69&lt;/code&gt;) triggers an immediate &lt;code&gt;ChainError.HashMismatch&lt;/code&gt;. The state transition is rejected atomically.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;built-for-developer-ergonomics&quot;&gt;Built for Developer Ergonomics&lt;/h3&gt;
&lt;p&gt;Security tools often fail because they make local development painful. Nobody wants to spin up a signing server just to preview a Compose Multiplatform screen or write a quick UI test.&lt;/p&gt;
&lt;p&gt;That’s why &lt;code&gt;TrustChain&lt;/code&gt; includes a &lt;code&gt;strictMode&lt;/code&gt; flag:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;strictMode = true&lt;/code&gt; (Default / Production):&lt;/strong&gt; Enforces parent hash lineage on &lt;code&gt;Push&lt;/code&gt; and &lt;code&gt;Replace&lt;/code&gt;, verifies digital signatures via &lt;code&gt;SignatureVerifier&lt;/code&gt;, and ensures &lt;code&gt;BlockPatcher&lt;/code&gt; updates block hashes accurately.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;strictMode = false&lt;/code&gt; (Previews / UI Prototyping):&lt;/strong&gt; Bypasses cryptographic and lineage checks while keeping the exact same &lt;code&gt;ChainEvent&lt;/code&gt; reducer API, allowing you to mock and jump between screens freely during development.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;where-does-trustchain-shine-&quot;&gt;Where Does TrustChain Shine?&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Server-Driven UI (SDUI):&lt;/strong&gt; Guarantee that screens and dynamic patches pushed by the backend are rendered in the exact sequence intended by the server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;E-Commerce &amp;amp; Checkouts:&lt;/strong&gt; Make it impossible to bypass delivery calculation, tax steps, or terms acceptance before payment.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fintech &amp;amp; Banking:&lt;/strong&gt; Enforce strict step ordering for wire transfers, credit limit increases, and multi-factor challenges.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;KYC &amp;amp; Onboarding Funnels:&lt;/strong&gt; Maintain a verifiable, tamper-proof progression through document capture and liveness checks.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;try-it-in-your-project&quot;&gt;Try It in Your Project&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;TrustChain&lt;/strong&gt; is open-source under the Apache 2.0 License and published directly to Maven Central.&lt;/p&gt;
&lt;p&gt;Check out the full source code, architecture docs, and the runnable CLI attack simulation on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/numq/trustchain&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31019/https-wont-save-your-checkout-flow-building-zero-trust-state-machines-kotlin-multiplatform</guid>
<pubDate>Wed, 14 Oct 2026 06:29:28 +0000</pubDate>
</item>
<item>
<title>Telemetry: Shared Analytics and Crash Reporting in Kotlin Multiplatform</title>
<link>https://coderlegion.com/30479/telemetry-shared-analytics-and-crash-reporting-in-kotlin-multiplatform</link>
<description>&lt;p&gt;In a modern endurance car, every sensor talks to the same box. Tyre pressure, brake temperature, fuel flow, throttle trace. They all end up in one data stream that goes to the pit wall over a single radio link. The engineer on the wall doesn't care which supplier made the brake sensor. He cares that the channel is called &lt;code&gt;BRAKE_TEMP_FL&lt;/code&gt; and that the number arrives every 100 milliseconds.&lt;/p&gt;
&lt;p&gt;Swap a sensor, the channel name stays. That's the whole point of the standard.&lt;/p&gt;
&lt;p&gt;Analytics and crash reporting in a KMP app should work the same way. The sensors are the SDKs, one per platform, made by different teams with different APIs. The channel names are yours.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-i-had-before&quot;&gt;What I had before&lt;/h3&gt;
&lt;p&gt;My first KMP app logged analytics twice. Once in Kotlin for Android, once in Swift for iOS. Same events, same names, in theory.&lt;/p&gt;
&lt;p&gt;In practice, the Android side sent &lt;code&gt;screen_view&lt;/code&gt; with a &lt;code&gt;screen_name&lt;/code&gt; parameter. The iOS side sent &lt;code&gt;screenView&lt;/code&gt; with &lt;code&gt;screenName&lt;/code&gt;. Nobody noticed for six weeks, until I tried to build a funnel in the dashboard and half the users were missing from it. The numbers weren't wrong. They were split across two event names that meant the same thing.&lt;/p&gt;
&lt;p&gt;That's the real cost of leaving telemetry in the platform layer. Not the duplicated code, which is small. The drift. Two implementations of the same contract, with no compiler checking that they agree.&lt;/p&gt;
&lt;p&gt;If you read &lt;a rel=&quot;nofollow&quot; href=&quot;/posts/feature-flags-kmp&quot;&gt;Change the Map&lt;/a&gt;, this is the same move applied to a different problem: the contract lives in &lt;code&gt;commonMain&lt;/code&gt;, the SDKs sit underneath. It also pairs with flags directly, because the first event worth logging is which variant a user was actually shown.&lt;/p&gt;
&lt;p&gt;Crash reporting has a quieter version of the same problem. A Kotlin exception that crashes the Android app shows up in Crashlytics with a readable stack trace. The same exception on iOS arrives as a native crash with frames that look like &lt;code&gt;kfun:...&lt;/code&gt;, and the Kotlin type is buried in the message. I'll get to that.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-interface-lives-in-commonmain&quot;&gt;The interface lives in commonMain&lt;/h3&gt;
&lt;p&gt;The fix is to stop treating analytics as a platform feature and start treating it as a domain contract.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
interface Analytics {
    fun track(event: AnalyticsEvent)
    fun setUserId(id: String?)
}

sealed interface AnalyticsEvent {
    val name: String
    val params: Map&amp;lt;String, String&amp;gt;

    data class ScreenView(val screen: String) : AnalyticsEvent {
        override val name = &quot;screen_view&quot;
        override val params = mapOf(&quot;screen_name&quot; to screen)
    }

    data class Purchase(val sku: String, val amountCents: Long) : AnalyticsEvent {
        override val name = &quot;purchase&quot;
        override val params = mapOf(
            &quot;sku&quot; to sku,
            &quot;amount_cents&quot; to amountCents.toString(),
        )
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A sealed interface for events is the part that pays for itself. The event name and the parameter keys exist in exactly one place. A new event is a new class, and the compiler tells every &lt;code&gt;when&lt;/code&gt; that cares about it. A typo in &lt;code&gt;&quot;screen_name&quot;&lt;/code&gt; is no longer possible in two files, because it's only written once.&lt;/p&gt;
&lt;p&gt;I keep params as &lt;code&gt;Map&amp;lt;String, String&amp;gt;&lt;/code&gt; on purpose. Firebase, Mixpanel, and Amplitude all accept richer types, but they disagree about which ones. Strings are the lowest common denominator, and the conversion cost is trivial.&lt;/p&gt;
&lt;p&gt;The crash side is a second, separate interface.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
interface CrashReporter {
    fun log(message: String)
    fun recordNonFatal(throwable: Throwable)
    fun setUserId(id: String?)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two interfaces, not one. Analytics is about what users did. Crash reporting is about what broke. They often go to different vendors, and you'll want to swap one without touching the other.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;android-the-easy-side&quot;&gt;Android: the easy side&lt;/h3&gt;
&lt;p&gt;On Android the implementation is a thin wrapper. Firebase is the obvious choice, though nothing here is Firebase-specific.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// androidMain
class FirebaseAnalyticsImpl(
    private val firebase: FirebaseAnalytics,
) : Analytics {

    override fun track(event: AnalyticsEvent) {
        val bundle = Bundle().apply {
            event.params.forEach { (k, v) -&amp;gt; putString(k, v) }
        }
        firebase.logEvent(event.name, bundle)
    }

    override fun setUserId(id: String?) {
        firebase.setUserId(id)
    }
}

class CrashlyticsReporter(
    private val crashlytics: FirebaseCrashlytics,
) : CrashReporter {

    override fun log(message: String) = crashlytics.log(message)

    override fun recordNonFatal(throwable: Throwable) =
        crashlytics.recordException(throwable)

    override fun setUserId(id: String?) {
        crashlytics.setUserId(id.orEmpty())
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing clever. If the Android side is the only one you ever shipped, this is the code you already had.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;ios-the-swift-bridge&quot;&gt;iOS: the Swift bridge&lt;/h3&gt;
&lt;p&gt;The simplest iOS implementation doesn't call the Firebase iOS SDK from Kotlin at all. No &lt;code&gt;expect&lt;/code&gt;/&lt;code&gt;actual&lt;/code&gt; in this layer, no cinterop bindings, no CocoaPods dependency in the shared module.&lt;/p&gt;
&lt;p&gt;The shared module defines the interface, and the iOS app passes in a Swift implementation at startup.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// iosApp
final class FirebaseAnalyticsBridge: Analytics {

    func track(event: AnalyticsEvent) {
        FirebaseAnalytics.Analytics.logEvent(event.name, parameters: event.params)
    }

    func setUserId(id: String?) {
        FirebaseAnalytics.Analytics.setUserID(id)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Swift sees the Kotlin interface as a protocol, so conforming to it is ordinary Swift. The one snag is the name: Firebase's Swift class is also called &lt;code&gt;Analytics&lt;/code&gt;, so inside this file the bare name is ambiguous. I qualify the Firebase one with its module. Renaming the Kotlin interface would also work, but I'd rather not let a vendor pick my names. The app registers it with the DI container before the first screen renders.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/Telemetry.kt
fun setUpTelemetry(analytics: Analytics, crashReporter: CrashReporter) {
    // register both in your DI graph of choice
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// iosApp
@main
struct IosApp: App {
    init() {
        TelemetryKt.setUpTelemetry(
            analytics: FirebaseAnalyticsBridge(),
            crashReporter: CrashlyticsBridge()
        )
    }
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I don't start that function name with &lt;code&gt;init&lt;/code&gt;. The Objective-C export reserves &lt;code&gt;init&lt;/code&gt; for initializers, so a Kotlin function called &lt;code&gt;initTelemetry&lt;/code&gt; shows up in Swift as &lt;code&gt;doInitTelemetry&lt;/code&gt;. It works, but it's the kind of thing that costs ten minutes the first time you see it.&lt;/p&gt;
&lt;p&gt;The Firebase SDK stays where Xcode manages it. The shared module never learns it exists.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;two-other-ways-to-bring-the-sdk-in&quot;&gt;Two other ways to bring the SDK in&lt;/h3&gt;
&lt;p&gt;That Swift bridge is how I wrote this in my first KMP apps, and it's the version I'd explain to anyone starting out because every moving part is visible. It isn't what I use today.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GitLive's Firebase Kotlin SDK.&lt;/strong&gt; &lt;code&gt;firebase-kotlin-sdk&lt;/code&gt; from GitLive wraps Firebase so you can call it from &lt;code&gt;commonMain&lt;/code&gt;. I haven't shipped it myself. If you use it, your &lt;code&gt;Analytics&lt;/code&gt; interface still sits in front of it, otherwise you've just moved the vendor lock-in into shared code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CocoaPods through Gradle, with the call in &lt;code&gt;iosMain&lt;/code&gt;.&lt;/strong&gt; This is what I use now. The Kotlin CocoaPods plugin lets the shared module declare the Firebase pod itself, and Kotlin generates the bindings so &lt;code&gt;iosMain&lt;/code&gt; can call the iOS SDK directly.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// build.gradle.kts (shared module)
plugins {
    kotlin(&quot;multiplatform&quot;)
    kotlin(&quot;native.cocoapods&quot;)
}

kotlin {
    cocoapods {
        version = &quot;1.0&quot;
        ios.deploymentTarget = &quot;16.0&quot;
        pod(&quot;FirebaseAnalytics&quot;)
        pod(&quot;FirebaseCrashlytics&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can pin a version with &lt;code&gt;pod(&quot;FirebaseAnalytics&quot;, &quot;~&amp;gt; 12.0&quot;)&lt;/code&gt;, and &lt;code&gt;linkOnly = true&lt;/code&gt; exists for pods you only need to link and not bind. The Kotlin docs cover both.&lt;/p&gt;
&lt;p&gt;Then the iOS implementation is Kotlin, in the same module as the interface.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// iosMain
import cocoapods.FirebaseAnalytics.FIRAnalytics
import kotlinx.cinterop.ExperimentalForeignApi

@OptIn(ExperimentalForeignApi::class)
class IosAnalytics : Analytics {

    @Suppress(&quot;UNCHECKED_CAST&quot;)
    override fun track(event: AnalyticsEvent) {
        FIRAnalytics.logEventWithName(event.name, event.params as Map&amp;lt;Any?, *&amp;gt;)
    }

    override fun setUserId(id: String?) {
        FIRAnalytics.setUserID(id)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The bindings come from the Objective-C headers, not the Swift API, so the class is &lt;code&gt;FIRAnalytics&lt;/code&gt; and the method is &lt;code&gt;logEventWithName&lt;/code&gt;. The &lt;code&gt;parameters&lt;/code&gt; dictionary arrives as &lt;code&gt;Map&amp;lt;Any?, *&amp;gt;?&lt;/code&gt;. A &lt;code&gt;Map&amp;lt;String, String&amp;gt;&lt;/code&gt; doesn't fit that type as-is, because map keys are invariant in Kotlin, hence the cast.&lt;/p&gt;
&lt;p&gt;I switched because the Swift bridge had a cost I only noticed over time: two files per interface, in two languages, in two repos' worth of mental context. With the pod declared in Gradle, the iOS implementation sits next to the Android one, in Kotlin, and a new method on &lt;code&gt;Analytics&lt;/code&gt; fails the build in the same module.&lt;/p&gt;
&lt;p&gt;It has its own cost. Pod dependencies and cinterop bindings are now part of your shared module's build, so Gradle sync and iOS builds feel it, and a Firebase SDK bump can break the generated bindings. It's also the option with an expiry date: the Kotlin docs now carry a CocoaPods-to-SwiftPM migration guide, and the new &lt;code&gt;swiftPMDependencies { }&lt;/code&gt; block replaces &lt;code&gt;cocoapods { }&lt;/code&gt;. The docs I read put the minimum Kotlin Gradle plugin at 2.4.20-RC3 for it, so check your version before planning the move. That's the trade I accept for keeping everything telemetry-related in one language.&lt;/p&gt;
&lt;p&gt;I'm showing three options on purpose, and Firebase is only the example. A KMP project has more than one way to bring a native dependency in: a bridge the app injects, a KMP wrapper library, or a native dependency declared in Gradle. Which one fits depends on how much of the native SDK surface you need, and who maintains the glue.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;kotlin-exceptions-on-ios-are-not-kotlin-exceptions&quot;&gt;Kotlin exceptions on iOS are not Kotlin exceptions&lt;/h3&gt;
&lt;p&gt;This is the part nobody mentions until you're staring at a dashboard.&lt;/p&gt;
&lt;p&gt;When an uncaught Kotlin exception terminates the app on iOS, the crash is reported by the native crash handler. The stack trace is the native one. The Kotlin frames are there, but unsymbolicated Kotlin frames read as noise, and the exception type is flattened. Touchlab's CrashKiOS exists for exactly this: it sends symbolicated Kotlin crashes and handled exceptions to Crashlytics, with breadcrumbs and custom keys. Its docs page was last updated in January 2024, so check the repo for recent activity before you rely on it.&lt;/p&gt;
&lt;p&gt;For non-fatal errors you have a better option. Catch the exception at a boundary you control, and hand it to &lt;code&gt;recordNonFatal&lt;/code&gt;. On iOS that means converting it to an &lt;code&gt;NSError&lt;/code&gt;, and that's where it gets fiddly.&lt;/p&gt;
&lt;p&gt;Swift can't get much out of a &lt;code&gt;KotlinThrowable&lt;/code&gt; by itself. The Kotlin stack trace accessor on Native, &lt;code&gt;getStackTrace()&lt;/code&gt;, is a stdlib extension marked &lt;code&gt;@ExperimentalNativeApi&lt;/code&gt;, so I don't count on it being reachable from Swift. I let Kotlin do the work instead, with two small extensions in &lt;code&gt;iosMain&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// iosMain
fun Throwable.kotlinTypeName(): String =
    this::class.qualifiedName ?: &quot;UnknownKotlinThrowable&quot;

fun Throwable.kotlinStackTrace(): String = stackTraceToString()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;They extend a Kotlin class, so the export turns them into methods on &lt;code&gt;KotlinThrowable&lt;/code&gt; and Swift calls them like any other method.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// iosApp
final class CrashlyticsBridge: CrashReporter {

    func recordNonFatal(throwable: KotlinThrowable) {
        let error = NSError(
            domain: throwable.kotlinTypeName(),
            code: 0,
            userInfo: [
                NSLocalizedDescriptionKey: throwable.message ?? &quot;unknown&quot;,
                &quot;kotlin_stacktrace&quot;: throwable.kotlinStackTrace(),
            ]
        )
        Crashlytics.crashlytics().record(error: error)
    }

    func log(message: String) {
        Crashlytics.crashlytics().log(message)
    }

    func setUserId(id: String?) {
        Crashlytics.crashlytics().setUserID(id ?? &quot;&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Crashlytics turns &lt;code&gt;userInfo&lt;/code&gt; into key-value pairs in the keys section of the issue, so the full Kotlin trace sits right there. I put it there on purpose rather than hoping the dashboard parses the message, because it won't.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;domain&lt;/code&gt; choice matters more than it looks. For fatal crashes Crashlytics groups by stack trace, but the Firebase docs say logged errors are grouped by &lt;code&gt;domain&lt;/code&gt; and &lt;code&gt;code&lt;/code&gt;. A fixed domain like &lt;code&gt;&quot;kotlin&quot;&lt;/code&gt; with code &lt;code&gt;0&lt;/code&gt; would collapse every non-fatal into one issue. With the Kotlin type there, an &lt;code&gt;IllegalStateException&lt;/code&gt; and a network timeout stay separate. Keep unique values like user IDs or timestamps out of both fields, or you'll fragment the dashboard the other way. Crashlytics also only stores the most recent eight exceptions in a session, so &lt;code&gt;recordNonFatal&lt;/code&gt; is not a log.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;where-the-calls-go&quot;&gt;Where the calls go&lt;/h3&gt;
&lt;p&gt;The interface is only worth having if people use it in the same place. I let ViewModels own analytics events and nothing else.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
class CheckoutViewModel(
    private val analytics: Analytics,
    private val crashReporter: CrashReporter,
    private val repository: CheckoutRepository,
) : ViewModel() {

    fun onPay(sku: String, amountCents: Long) {
        viewModelScope.launch {
            runCatching { repository.pay(sku, amountCents) }
                .onSuccess {
                    analytics.track(AnalyticsEvent.Purchase(sku, amountCents))
                }
                .onFailure {
                    if (it is CancellationException) throw it
                    crashReporter.recordNonFatal(it)
                }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;CancellationException&lt;/code&gt; check is there because &lt;code&gt;runCatching&lt;/code&gt; catches it too. Without it, every user who leaves the screen mid-payment becomes a non-fatal in Crashlytics.&lt;/p&gt;
&lt;p&gt;Composables and SwiftUI views don't call &lt;code&gt;track&lt;/code&gt;. Screen views are the exception, and I fire them from a single navigation observer, so there's one line of code that produces every &lt;code&gt;screen_view&lt;/code&gt; in the app.&lt;/p&gt;
&lt;p&gt;The other benefit shows up in tests. A &lt;code&gt;FakeAnalytics&lt;/code&gt; that stores events in a list takes ten lines, and asserting on it in &lt;code&gt;commonTest&lt;/code&gt; is trivial. I used to skip testing analytics because it meant mocking an SDK. Now I assert that tapping Pay produces exactly one &lt;code&gt;Purchase&lt;/code&gt; with the right amount, and it runs on every target.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;gotchas&quot;&gt;Gotchas&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Don't init the SDKs inside commonMain, whichever option you pick.&lt;/strong&gt; Firebase on Android wants a &lt;code&gt;Context&lt;/code&gt;. On iOS it wants &lt;code&gt;FirebaseApp.configure()&lt;/code&gt; before anything else touches it. Those calls belong in &lt;code&gt;Application.onCreate&lt;/code&gt; and the iOS &lt;code&gt;App.init&lt;/code&gt;, not behind an abstraction that pretends the platforms are the same.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;User IDs are a privacy decision, not a plumbing one.&lt;/strong&gt; &lt;code&gt;setUserId&lt;/code&gt; is easy to wire and easy to get wrong. I pass an internal opaque ID and never an email. Deciding that once, in the interface docs, beats hoping every caller remembers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Consent has to be a gate, not a flag.&lt;/strong&gt; If your users can opt out, wrap the implementation, not the call sites. A &lt;code&gt;ConsentAwareAnalytics&lt;/code&gt; decorator that drops events when consent is off keeps the rule in one class. Crash reporting is different. &lt;code&gt;setCrashlyticsCollectionEnabled&lt;/code&gt; can be called at runtime, but the Firebase docs say the override applies from the next launch, so a user who opts out mid-session is still reporting until the app restarts. Say that in your consent screen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A debug build should not talk to production.&lt;/strong&gt; I give the debug variant a &lt;code&gt;LoggingAnalytics&lt;/code&gt; that prints to the console. It's another implementation of the same interface, which is the quiet argument for having the interface at all.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;Treat telemetry as a contract you own and a set of sensors you rent. The event names, the parameter keys, and the rule for what counts as a non-fatal live in &lt;code&gt;commonMain&lt;/code&gt;, in code your compiler can check. The SDKs sit underneath, one per platform, and nobody outside the platform layer should know which ones.&lt;/p&gt;
&lt;p&gt;I would still start with the interface even if the app only ships on one platform today. Adding iOS later then costs one Swift file instead of an audit of every event.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Same channel names, whatever the sensor. 🏁&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The KMP Bits app is available on &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/pt/app/bitsreader/id6755438235&quot;&gt;App Store&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://play.google.com/store/apps/details?id=com.joel.bitsreader&quot;&gt;Google Play&lt;/a&gt; — built entirely with KMP.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30479/telemetry-shared-analytics-and-crash-reporting-in-kotlin-multiplatform</guid>
<pubDate>Fri, 09 Oct 2026 14:00:00 +0000</pubDate>
</item>
<item>
<title>Minimum Weight: Code Coverage Gates in Kotlin Multiplatform with Kover</title>
<link>https://coderlegion.com/28289/minimum-weight-code-coverage-gates-in-kotlin-multiplatform-with-kover</link>
<description>&lt;p&gt;After a GT race, every car rolls onto the weighbridge before parc fermé. It has to clear a minimum weight, set before the season and revised only upward as the series collects more data on what's fair. A car that comes in light gets disqualified, no matter how well it drove. Teams don't get to argue the number down when it's inconvenient. They add ballast, or they find grip somewhere else.&lt;/p&gt;
&lt;p&gt;I think about that weighbridge every time I look at a coverage report.&lt;/p&gt;
&lt;p&gt;A percentage on its own tells you almost nothing. Ninety percent coverage on a module full of generated code and empty getters is worse than sixty percent on a module where every line was actually exercised by a test that would fail if you broke something. I found this out the annoying way, wiring Kover into a project with close to twenty modules: the number moved, but it wasn't telling me the truth.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-number-was-lying-before-it-was-useful&quot;&gt;The number was lying before it was useful&lt;/h3&gt;
&lt;p&gt;The first pass was simple. Apply the Kover plugin, point it at every module, generate a report. The merged number came out somewhere in the low sixties. Respectable enough on paper, wrong in a way that mattered.&lt;/p&gt;
&lt;p&gt;Domain modules with almost no real logic were sitting near the top of the report, and it wasn't because they were well tested. It was because a dependency injection library had generated a pile of factory classes and graph interfaces that every counted line inflated the denominator on, without a single one of them ever being able to fail a test. Compose &lt;code&gt;@Preview&lt;/code&gt; functions did the same thing in the other direction: pure decoration, zero tests, dragging modules with real logic down next to modules that had none.&lt;/p&gt;
&lt;p&gt;You can't set a meaningful floor on a number built like that. So before the floor came the exclude list, and before the exclude list came trusting the report at all.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;coverage-across-more-than-one-module&quot;&gt;Coverage across more than one module&lt;/h3&gt;
&lt;p&gt;A single-module Android app can get away with Kover's defaults: apply the plugin, run &lt;code&gt;koverHtmlReport&lt;/code&gt;, done. A multi-module KMP project can't, for two reasons.&lt;/p&gt;
&lt;p&gt;First, most modules don't need coverage measured at all. Pure API modules, thin platform-wrapper modules, anything with no meaningful branches, don't need a plugin dragging a bytecode instrumentation step into every build. Second, the number you actually care about is the &lt;em&gt;merged&lt;/em&gt; one across every module that does carry logic, not twenty separate reports you have to average in your head.&lt;/p&gt;
&lt;p&gt;The way I did was creating a convention plugin that applies Kover to a module and defines the excludes, plus a root-level aggregation that pulls the modules worth measuring into one merged report.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// build-logic/convention/src/main/kotlin/KoverConventionPlugin.kt
class KoverConventionPlugin : Plugin&amp;lt;Project&amp;gt; {
    override fun apply(target: Project) {
        with(target) {
            val libs = the&amp;lt;LibrariesForLibs&amp;gt;()
            pluginManager.apply(libs.plugins.kover.get().pluginId)

            extensions.configure&amp;lt;KoverProjectExtension&amp;gt; {
                reports {
                    filters {
                        excludes {
                            classes(
                                // DI-generated factories and contribution classes.
                                &quot;*\$Factory*&quot;,
                                &quot;*\$ContributionToAppScope*&quot;,
                                // Compose-compiler-generated lambda holders, no logic of their own.
                                &quot;*ComposableSingletons*&quot;,
                                &quot;*Previews&quot;,
                                &quot;*PreviewsKt&quot;,
                                // Generated resource accessors.
                                &quot;*.generated.resources.*&quot;,
                                // Pure DI wiring: every class under a `.di.` package here
                                // is graph construction, nothing to unit test.
                                &quot;*.di.*&quot;,
                            )
                        }
                    }
                }
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Any module with tests worth counting applies this plugin instead of wiring Kover from scratch:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// core/network/build.gradle.kts
plugins {
    id(&quot;template.kover&quot;)
    // ...other convention plugins
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The root project then declares which modules feed the merged report, and repeats the same excludes so the merged view and each module's own view agree with each other:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// build.gradle.kts (root)
plugins {
    alias(libs.plugins.kover)
}

dependencies {
    // Only modules with real tests are listed here. A module with nothing
    // to measure doesn't need to show up in the merged number at all.
    kover(project(&quot;:core:common&quot;))
    kover(project(&quot;:core:network&quot;))
    kover(project(&quot;:core:database&quot;))
    kover(project(&quot;:feature:auth:domain&quot;))
    kover(project(&quot;:feature:auth:data&quot;))
    kover(project(&quot;:feature:profile:presentation&quot;))
}

kover {
    reports {
        filters {
            excludes {
                // Keep in sync with KoverConventionPlugin's excludes.
                classes(
                    &quot;*\$Factory*&quot;,
                    &quot;*\$ContributionToAppScope*&quot;,
                    &quot;*ComposableSingletons*&quot;,
                    &quot;*Previews&quot;,
                    &quot;*PreviewsKt&quot;,
                    &quot;*.generated.resources.*&quot;,
                    &quot;*.di.*&quot;,
                )
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &quot;keep in sync&quot; comment is doing real work. Kover reads excludes from wherever the report is generated. Exclude a class in the convention plugin but forget the same line in the root aggregation, and the merged report counts it again, quietly, and your floor starts failing for a reason that has nothing to do with a real regression.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;excluding-the-honest-way&quot;&gt;Excluding the honest way&lt;/h3&gt;
&lt;p&gt;The instinct when a module reads 0% is to exclude your way to a better number. That's the wrong direction to pull. The right question for anything you're about to exclude is: could a test on this class ever fail for a real reason? If yes, it stays in the count, even if writing that test is annoying. If no, excluding it isn't gaming the number, it's correcting a measurement error.&lt;/p&gt;
&lt;p&gt;Generated database entities and query classes are the clearest case. A SQLDelight-generated &lt;code&gt;UserEntityQueries&lt;/code&gt; class has no branches a test could meaningfully assert on beyond &quot;does the generated SQL run,&quot; which your actual repository tests already cover indirectly.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;excludes {
    classes(
        &quot;com.example.app.core.database.UserEntity&quot;,
        &quot;com.example.app.core.database.UserEntityQueries&quot;,
        &quot;com.example.app.core.database.UserEntityQueries\$*&quot;,
    )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The exclude list will grow every time you add a code generator.&lt;/strong&gt; A new annotation processor, a new resource pipeline, a new DI library migration, each one leaves generated classes in its wake that need the same treatment. Treat additions to this list the same way you'd treat additions to &lt;code&gt;.gitignore&lt;/code&gt;: expected maintenance, not a sign something's wrong.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-floor-that-only-goes-up&quot;&gt;The floor that only goes up&lt;/h3&gt;
&lt;p&gt;Once the report was honest, setting a floor was the easy part.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;kover {
    reports {
        verify {
            rule {
                // Baseline was ~64% after excluding generated code.
                // Raised to 68% once core/network got real coverage.
                // Raised to 75% after the auth data layer got tests
                // for its *RepositoryImpl classes.
                // Never lower this to make a failing build pass.
                minBound(75)
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;koverVerify&lt;/code&gt; fails the build if merged line coverage drops below that number. The number itself is a ratchet, not a target. Every time real tests push the baseline up, the floor moves up behind it, a couple of points below the current baseline so unrelated refactors don't fail the build on noise. It never moves down. If a change makes coverage drop, the fix is to write the missing tests, not to edit the floor.&lt;/p&gt;
&lt;p&gt;That's the whole point of the weighbridge analogy holding up under pressure. A minimum weight limit that teams could talk their way under whenever it was inconvenient wouldn't be a rule, it would be a suggestion. Same with a coverage floor you lower the first time it's in your way.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wiring-it-into-ci&quot;&gt;Wiring it into CI&lt;/h3&gt;
&lt;p&gt;Locally, &lt;code&gt;koverVerify&lt;/code&gt; runs alongside &lt;code&gt;koverHtmlReport&lt;/code&gt; and &lt;code&gt;koverXmlReport&lt;/code&gt; as part of the same pre-merge check that runs Detekt (covered in &lt;a rel=&quot;nofollow&quot; href=&quot;/posts/detekt-custom-rules-kmp&quot;&gt;The White Lines&lt;/a&gt;). Coverage belongs in the same slot: a gate that runs before a PR is allowed to merge, not a report someone checks manually once a month.&lt;/p&gt;
&lt;p&gt;If your KMP project already has a GitHub Actions workflow running unit tests on every pull request, adding a coverage gate is one more step, not a new pipeline:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# .github/workflows/tests.yml
name: Unit Tests
on:
  pull_request:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: 'gradle'

      - name: Run unit tests
        run: ./gradlew testDebugUnitTest --max-workers=2

      - name: Enforce coverage floor
        run: ./gradlew koverXmlReport koverVerify

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: kover-report
          path: build/reports/kover/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;koverVerify&lt;/code&gt; fails the job the same way a failing test does, so there's no extra branching logic to write. The XML report is worth generating even if nothing downstream reads it yet: it's the format most coverage-badge and PR-annotation tools expect, and generating it costs nothing extra once &lt;code&gt;koverXmlReport&lt;/code&gt; is already in the command.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One gotcha if you're running this on Android modules with Robolectric tests:&lt;/strong&gt; those tests are memory-hungry, and Kover's instrumentation adds overhead on top. If the job runs out of memory before it gets anywhere near reporting a real coverage number, raise the runner's heap before you start suspecting the plugin:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;env:
  GRADLE_OPTS: -Dorg.gradle.jvmargs=&quot;-Xmx4g -XX:MaxMetaspaceSize=1g&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-line-i-hold&quot;&gt;The line I hold&lt;/h3&gt;
&lt;p&gt;A coverage percentage nobody enforces is decoration. It sits at the top of a README, it looks fine in a badge, and it means nothing because there's no consequence when it drops. The moment &lt;code&gt;koverVerify&lt;/code&gt; runs on every pull request and fails the build, the number stops being decoration and starts being a rule the team actually has to answer to.&lt;/p&gt;
&lt;p&gt;I'd rather have a floor at seventy-five that only goes up than a badge that says ninety and hasn't been checked since the module was scaffolded.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Everyone crosses the weighbridge. Nobody gets to argue the number down. 🏁&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28289/minimum-weight-code-coverage-gates-in-kotlin-multiplatform-with-kover</guid>
<pubDate>Wed, 23 Sep 2026 09:00:00 +0000</pubDate>
</item>
<item>
<title>Same Car, Every Time: Deterministic Feature Module Generation in KMP</title>
<link>https://coderlegion.com/27291/same-car-every-time-deterministic-feature-module-generation-in-kmp</link>
<description>&lt;p&gt;Walk down the pit lane at a Porsche Cup weekend and every garage holds the same car. Same chassis from the same jig, same engine sealed by the same people, same tolerances checked against the same sheet. Nobody on that grid gets to be creative about the monocoque. It isn't a lack of ambition, it's the entire point: when thirty cars are identical, a tenth of a second means something. You learn from the difference because the baseline never moves.&lt;/p&gt;
&lt;p&gt;A Formula 1 grid gives you brilliant engineering and twenty-two cars where no two problems have the same cause. Both are real forms of racing. They answer different questions.&lt;/p&gt;
&lt;p&gt;Feature modules are a spec series. I want every one of mine built to the same jig, so that when a module behaves oddly I know it's the code I wrote and not the scaffold it arrived in. It took me a while, and one AI-generated codebase, to work out that I'd been running Formula 1.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;A while back I wrote about custom Gemini commands in Android Studio, and the one I used most was the one that created KMP feature modules. The argument was simple: write down what your project looks like once, stop explaining it from scratch every time. I still believe that. The command file worked.&lt;/p&gt;
&lt;p&gt;Then I went back and read three features I'd generated over about two months.&lt;/p&gt;
&lt;p&gt;One had &lt;code&gt;implementation(project(&quot;:feature:x:domain&quot;))&lt;/code&gt; inside &lt;code&gt;commonMain.dependencies&lt;/code&gt;. One had it at the top level of the &lt;code&gt;kotlin&lt;/code&gt; block. One had it in both places, which somehow still compiled. The &lt;code&gt;ViewModel&lt;/code&gt; in the oldest one extended a base class signature I'd changed since. The package for a hyphenated feature name had come out as &lt;code&gt;user-profile&lt;/code&gt; in one file and &lt;code&gt;user_profile&lt;/code&gt; in the rest, so that module simply didn't build until I fixed it by hand.&lt;/p&gt;
&lt;p&gt;None of these were dramatic failures. Every single one was a five minute fix. But five minutes times four files times every feature is exactly the interruption I built the command to avoid, and now I was paying it at review time instead of at creation time, which is worse, because at review time I've already stopped thinking about this feature.&lt;/p&gt;
&lt;p&gt;So I stopped asking and wrote a bash script.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-thing-i-actually-wanted-was-not-intelligence&quot;&gt;The thing I actually wanted was not intelligence&lt;/h3&gt;
&lt;p&gt;Here's the part that took me embarrassingly long to accept: for this specific job, the AI's greatest strength is the problem.&lt;/p&gt;
&lt;p&gt;An AI generator reads your instructions, reads your codebase, and produces something reasonable. &quot;Reasonable&quot; is a range. It has to be, because the model is inferring intent every time. Ask it twice and you get two points inside that range, both defensible, neither identical. That's exactly what you want when the task is genuinely novel and you can't specify it up front.&lt;/p&gt;
&lt;p&gt;Scaffolding is not that task. Scaffolding is the opposite of that task. I know precisely what the output should be, down to the import order, because I wrote the module it's copying. There is nothing to infer. Every degree of freedom the model has is a degree of freedom I didn't ask for.&lt;/p&gt;
&lt;p&gt;What I wanted was a fixed baseline. Not better output, identical output, so that any difference between two modules is information rather than noise.&lt;/p&gt;
&lt;p&gt;Which raises the fair question of why bash, when a Gradle plugin or an IntelliJ file template would also be deterministic. Both were on the table. The template lost because it stops at files: it can't append to &lt;code&gt;settings.gradle.kts&lt;/code&gt; or splice a route into an existing sealed interface, which is most of what makes a new module actually usable. The Gradle plugin lost on cost. It's the better answer if you're handing this to a team of fifteen, but it's a &lt;code&gt;build-logic&lt;/code&gt; module to maintain and a Gradle build to re-run to test a change, whereas the script is one file I can read top to bottom and edit in place. If the thing ever outgrows bash I'll port it. It hasn't.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-the-script-generates&quot;&gt;What the script generates&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;scripts/new-feature.sh&lt;/code&gt; takes a kebab-case feature name and produces up to three Gradle modules under &lt;code&gt;feature/&amp;lt;name&amp;gt;/&lt;/code&gt;: &lt;code&gt;domain&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt;, &lt;code&gt;presentation&lt;/code&gt;. It mirrors the smallest real feature in the project, so the output isn't an idea of what my modules look like, it's a copy of one that already ships.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./scripts/new-feature.sh wishlist
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That gives you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feature/wishlist/domain&lt;/code&gt; with a &lt;code&gt;WishlistRepository&lt;/code&gt; interface&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feature/wishlist/data&lt;/code&gt; with &lt;code&gt;WishlistRepositoryImpl&lt;/code&gt;, bound through Metro&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feature/wishlist/presentation&lt;/code&gt; with &lt;code&gt;WishlistState&lt;/code&gt;, &lt;code&gt;WishlistEvent&lt;/code&gt;, &lt;code&gt;WishlistAction&lt;/code&gt;, &lt;code&gt;WishlistNavigator&lt;/code&gt;, &lt;code&gt;WishlistViewModel&lt;/code&gt;, &lt;code&gt;WishlistScreen&lt;/code&gt;, plus a &lt;code&gt;WishlistGraph&lt;/code&gt; and &lt;code&gt;WishlistGraphNavigator&lt;/code&gt; under &lt;code&gt;navigation/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;three &lt;code&gt;include(...)&lt;/code&gt; lines appended to &lt;code&gt;settings.gradle.kts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;NavigationRouter.Wishlist.Main&lt;/code&gt; route spliced into &lt;code&gt;NavigationRouter.kt&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It compiles and runs immediately. The screen is a &lt;code&gt;PlaceholderScreen&lt;/code&gt; inside a &lt;code&gt;ScaffoldScreen&lt;/code&gt;, the repository interface is empty, and the state holds a single &lt;code&gt;Unit&lt;/code&gt; field so the data class is valid. Nothing pretends to be finished.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;A note on the code you're about to read:&lt;/strong&gt; this is my script, shaped around my project. The paths, the package &lt;code&gt;com.example.template&lt;/code&gt;, Metro for DI, Navigation 3, &lt;code&gt;StateViewModel&lt;/code&gt; and &lt;code&gt;BaseState&lt;/code&gt; from my &lt;code&gt;core/ui&lt;/code&gt; module, the &lt;code&gt;template.*&lt;/code&gt; convention plugins. Dropped into your codebase as-is it won't generate anything that compiles, and that's expected rather than a problem. The value of a generator like this comes from it knowing your conventions, so the interesting part is the structure, not the contents. If you're on Koin instead of Metro, or MVI instead of this MVVM split, or one module per feature instead of three, the shape of the script stays and every heredoc inside it becomes yours. Treat what follows as a worked example to adapt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The name handling is the first thing that stops being a per-feature annoyance:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# kebab-case -&amp;gt; snake_case (Kotlin package segment) and PascalCase (class name prefix)
SNAKE=&quot;${KEBAB//-/_}&quot;
PASCAL=&quot;&quot;
IFS='-' read -ra WORDS &amp;lt;&amp;lt;&amp;lt; &quot;$KEBAB&quot;
for word in &quot;${WORDS[@]}&quot;; do
  first_upper=&quot;$(printf '%s' &quot;${word:0:1}&quot; | tr '[:lower:]' '[:upper:]')&quot;
  PASCAL=&quot;${PASCAL}${first_upper}${word:1}&quot;
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;user-profile&lt;/code&gt; becomes the folder &lt;code&gt;user-profile&lt;/code&gt;, the package segment &lt;code&gt;user_profile&lt;/code&gt;, and the class prefix &lt;code&gt;UserProfile&lt;/code&gt;. Three forms of the same name, derived once, used everywhere. This is the exact transformation an AI gets right most of the time. &quot;Most of the time&quot; is what shipped me a module that didn't build.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;guardrails-come-before-generation&quot;&gt;Guardrails come before generation&lt;/h3&gt;
&lt;p&gt;The script writes into &lt;code&gt;feature/&lt;/code&gt;, appends to &lt;code&gt;settings.gradle.kts&lt;/code&gt;, and rewrites &lt;code&gt;NavigationRouter.kt&lt;/code&gt; in place. Something that edits tracked files without asking should be paranoid about it, so nothing gets written until three checks pass. Two are obvious: the name has to match &lt;code&gt;^[a-z][a-z0-9]*(-[a-z0-9]+)*$&lt;/code&gt;, and &lt;code&gt;feature/$KEBAB&lt;/code&gt; must not already exist. The third is the one I care about:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;if [[ -n &quot;$(git status --porcelain)&quot; ]]; then
  echo &quot;Warning: you have uncommitted changes. This script edits settings.gradle.kts and&quot;
  echo &quot;NavigationRouter.kt in place, and creates new files under feature/$KEBAB.&quot;
  read -r -p &quot;Continue anyway? [y/N] &quot; confirm
  [[ &quot;$confirm&quot; =~ ^[Yy]$ ]] || exit 1
fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With a clean tree, &lt;code&gt;git diff&lt;/code&gt; after the run is a complete, readable record of everything the script touched. With a dirty tree, the generated changes are tangled up with yours and you lose the cheapest verification you have. The script can't force a clean tree, but it can make you type &lt;code&gt;y&lt;/code&gt; and think about it for a second.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;set -euo pipefail&lt;/code&gt; sits at the top, so anything unexpected stops the run rather than leaving half a feature on disk.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;then-i-needed-one-module-not-three&quot;&gt;Then I needed one module, not three&lt;/h3&gt;
&lt;p&gt;I wanted a &lt;code&gt;domain&lt;/code&gt; and &lt;code&gt;data&lt;/code&gt; pair with no UI. A feature that exposes a repository, consumed by an existing screen somewhere else. The script had exactly one mode: all three modules, every time.&lt;/p&gt;
&lt;p&gt;The obvious move, if you're used to AI generation, is to go back and describe the exception. &quot;Same as before, but skip presentation.&quot; And that works. It works right now, on the first try, which is precisely why it's a trap. Because next month I want &lt;code&gt;domain&lt;/code&gt; alone, and the month after I want &lt;code&gt;data&lt;/code&gt; and &lt;code&gt;presentation&lt;/code&gt; with the domain contract living in a shared module, and each of those is a fresh description of an exception to a structure that nobody has actually written down. Every exception is a new chance for the output to drift from the last one.&lt;/p&gt;
&lt;p&gt;Rigidity was the whole reason I wrote the script. The answer isn't to soften it, it's to make the flexibility part of the spec.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Usage: ./scripts/new-feature.sh &amp;lt;feature-name&amp;gt; [--modules &amp;lt;module&amp;gt; [&amp;lt;module&amp;gt; ...]]
./scripts/new-feature.sh wishlist                    # domain + data + presentation
./scripts/new-feature.sh wishlist --modules domain data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three booleans default to true, and &lt;code&gt;--modules&lt;/code&gt; flips them all off and turns back on only what you named:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;GEN_DOMAIN=false
GEN_DATA=false
GEN_PRES=false

for module in &quot;${MODULES[@]}&quot;; do
  case &quot;$module&quot; in
    domain) GEN_DOMAIN=true ;;
    data) GEN_DATA=true ;;
    presentation) GEN_PRES=true ;;
    *)
      echo &quot;Error: unknown module '$module'. Must be one of: domain, data, presentation.&quot; &amp;gt;&amp;amp;2
      exit 1
      ;;
  esac
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The flag accepts at most two names, deliberately. &lt;code&gt;--modules domain data presentation&lt;/code&gt; is already the default, and offering two ways to say the same thing is how flags start rotting. If you want all three, don't pass the flag.&lt;/p&gt;
&lt;p&gt;That cap has a hole you only find by running your own tool wrong: &lt;code&gt;--modules domain domain&lt;/code&gt; has a length of two, passes the count check, and sets one boolean. Comparing how many booleans ended up true against how many names were typed catches it in three lines. Small thing, and the sort of edge case no amount of prompting reliably covers, because you'd have to think of it in advance to ask for it — and once you've thought of it you may as well encode it.&lt;/p&gt;
&lt;p&gt;Notice what this is: a flag with a validated, closed set of values. The flexibility is real and it's bounded. Every combination that's allowed is a combination I decided was allowed, and the ones I didn't decide on fail loudly at argument parsing rather than quietly at compile time.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conditionals-are-where-the-real-work-is&quot;&gt;Conditionals are where the real work is&lt;/h3&gt;
&lt;p&gt;Adding the flag was twenty minutes. Making the &lt;em&gt;generated code&lt;/em&gt; correct for each combination was the rest of the afternoon, and this is the part I think matters most.&lt;/p&gt;
&lt;p&gt;If you generate &lt;code&gt;data&lt;/code&gt; without &lt;code&gt;domain&lt;/code&gt;, the data module's &lt;code&gt;build.gradle.kts&lt;/code&gt; must not depend on a domain module that doesn't exist. Neither branch is clever, and that's the point:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;if $GEN_DOMAIN; then
  cat &amp;gt; &quot;feature/$KEBAB/data/build.gradle.kts&quot; &amp;lt;&amp;lt;EOF
import com.android.build.api.dsl.LibraryExtension

plugins {
    alias(libs.plugins.template.data)
}

extensions.configure&amp;lt;LibraryExtension&amp;gt;(&quot;android&quot;) {
    namespace = &quot;com.example.template.feature.$SNAKE.data&quot;
}

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(project(&quot;:feature:$KEBAB:domain&quot;))
        }
    }
}
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and the &lt;code&gt;else&lt;/code&gt; branch is the same file with the whole &lt;code&gt;kotlin { }&lt;/code&gt; block gone.&lt;/p&gt;
&lt;p&gt;Those &lt;code&gt;alias(libs.plugins.template.data)&lt;/code&gt; lines are convention plugins from my &lt;code&gt;build-logic&lt;/code&gt; module. Each layer gets one plugin ID, and everything else, targets, compiler options, common dependencies, lives centrally. It's the same setup I mentioned in the Gemini article. Worth noting here because it's part of why the generated Gradle files are short enough to template safely: there's very little per-module configuration left to get wrong.&lt;/p&gt;
&lt;p&gt;The second consequence is more interesting. Without a domain module, there is no &lt;code&gt;WishlistRepository&lt;/code&gt;, so &lt;code&gt;WishlistRepositoryImpl&lt;/code&gt; has nothing to implement and nothing to bind. The script doesn't generate a degraded version of it. It doesn't generate it at all, and it tells you why:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo &quot;Note: domain not generated — feature/$KEBAB/data has no ${PASCAL}RepositoryImpl&quot; \
  &quot;(it would need ${PASCAL}Repository from the domain module).&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That line took a couple of iterations to get right and I'd argue it's the most valuable output the script produces. A generator that silently omits things trains you to distrust it. A generator that says &quot;I left this out, here's the reason&quot; is a tool you can stop checking. The absence is intentional and documented at the moment it happens, in the terminal, while you're still looking.&lt;/p&gt;
&lt;p&gt;Same story for the route registration. &lt;code&gt;NavigationRouter.Wishlist.Main&lt;/code&gt; only exists to be referenced by &lt;code&gt;WishlistGraph&lt;/code&gt;, which lives in the presentation module. No presentation, no route:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;if $GEN_PRES; then
  ROUTER_FILE=&quot;core/ui/src/commonMain/kotlin/com/example/template/core/ui/navigation/NavigationRouter.kt&quot;
  # ... splice the route in
fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three modules, generated or not, gives eight combinations if you count naively, six that the argument parser actually allows. Every one produces code that compiles. That's the property I was buying, and it's a property you can verify by running the thing six times, which is not something you can meaningfully do with a prompt.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-one-clever-bit-and-its-escape-hatch&quot;&gt;The one clever bit, and its escape hatch&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;NavigationRouter.kt&lt;/code&gt; is a sealed interface hierarchy, and new routes go in as nested &lt;code&gt;data object&lt;/code&gt; declarations before the final closing brace. There's no marker comment, no anchor, just a &lt;code&gt;}&lt;/code&gt; on its own line at the end of the file.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;LAST_BRACE_LINE=$(grep -n '^}$' &quot;$ROUTER_FILE&quot; | tail -1 | cut -d: -f1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Find every line that is exactly a closing brace, take the last one, extract the line number. Then rebuild the file in three pieces:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;{
  head -n &quot;$((LAST_BRACE_LINE - 1))&quot; &quot;$ROUTER_FILE&quot;
  cat &amp;lt;&amp;lt;EOF

    @Serializable
    data object $PASCAL : NavigationRouter {
        @Serializable
        data object Main : NavigationRouter
    }
EOF
  tail -n &quot;+$LAST_BRACE_LINE&quot; &quot;$ROUTER_FILE&quot;
} &amp;gt; &quot;$ROUTER_FILE.tmp&quot;
mv &quot;$ROUTER_FILE.tmp&quot; &quot;$ROUTER_FILE&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Everything before the brace, the new block, then the brace and anything after it. Written to a temp file and moved into place, so a failure halfway through leaves the original intact rather than truncated.&lt;/p&gt;
&lt;p&gt;I'm aware this is line-based surgery on Kotlin source, and that it depends on a formatting convention holding. Which is why the fallback exists:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;if [[ -z &quot;$LAST_BRACE_LINE&quot; ]]; then
  echo &quot;Warning: couldn't find NavigationRouter.kt's closing brace — add the route manually:&quot;
  echo &quot;    @Serializable&quot;
  echo &quot;    data object $PASCAL : NavigationRouter {&quot;
  echo &quot;        @Serializable&quot;
  echo &quot;        data object Main : NavigationRouter&quot;
  echo &quot;    }&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the assumption breaks, the script says so and prints exactly what you need to paste. It does not guess at an alternative insertion point. A generator that fails visibly and hands you the fix costs you thirty seconds. A generator that improvises when its assumptions break costs you a debugging session, and you won't know to start one, because it looked like it worked.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-it-deliberately-refuses-to-do&quot;&gt;What it deliberately refuses to do&lt;/h3&gt;
&lt;p&gt;Two things the script will not touch, and both refusals are in the header comment so I can't quietly forget them.&lt;/p&gt;
&lt;p&gt;It won't wire &lt;code&gt;WishlistGraph&lt;/code&gt; into anything. A new feature can live in a bottom nav tab, behind a settings row, in a shared overlay, or somewhere I haven't invented yet, and that is an architecture decision with consequences. A generator making it for me is a generator making an architecture decision I didn't review.&lt;/p&gt;
&lt;p&gt;It won't write domain logic. &lt;code&gt;WishlistRepository&lt;/code&gt; is an empty interface with a comment telling you to define the contract. That's the actual work. The script's job was to clear a path to it.&lt;/p&gt;
&lt;p&gt;So the run ends with a list rather than a claim of completion:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Still manual:
  - Wire WishlistGraph(onNavigation = ...) into wherever this feature actually lives
  - Fill in WishlistRepository / WishlistRepositoryImpl with real logic.
  - Run './gradlew :feature:wishlist:domain:detekt ...' (or just commit — the pre-commit hook covers it).
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Building the detekt task list from the same booleans is a two-line addition, and it means the command you're told to run is always the right one for what was actually generated.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;when-i-still-reach-for-the-ai&quot;&gt;When I still reach for the AI&lt;/h3&gt;
&lt;p&gt;I want to be clear that I didn't replace one tool with a better one. I split one job into two jobs.&lt;/p&gt;
&lt;p&gt;The script handles the part I've done forty times and will do forty more, where I know the answer and want it applied identically every time. The AI handles everything after that: the domain logic, the state modelling for a screen that doesn't resemble any other screen, the migration that touches nine files in ways I can't specify up front. That's genuinely novel work, and the ability to infer intent from a loose description is exactly what makes it useful there.&lt;/p&gt;
&lt;p&gt;There's also a nice side effect. The AI is now working inside a codebase where every feature module has the same shape, because the shape came from a script rather than from six separate acts of interpretation. Consistent context produces better output. The rigid tool makes the flexible one work better.&lt;/p&gt;
&lt;p&gt;A rough test I use now: if I'm about to write a prompt for the third time and it's substantially the same prompt, that's not a prompt, that's a script I haven't written yet. And if the script needs to handle a case it doesn't, the fix is a flag with a validated set of values, not a paragraph of natural language explaining the exception.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-1&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;The Gemini article's conclusion was that you should write down what your project looks like once. This one is the next step: for the things you do constantly, write down &lt;em&gt;how&lt;/em&gt; as well, in something that executes rather than interprets. Not my how. Yours, with your package name and your base classes and your DI framework, which is an afternoon of work and the reason the thing is worth anything at all.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;--modules&lt;/code&gt; is the whole argument in miniature. I needed the generator to do something it couldn't do. I could have described the exception to a model in about fifteen seconds, and I'd have described it again, slightly differently, every time since. Instead I spent an afternoon making the exception a first-class, validated, six-combinations-all-of-which-compile part of the tool. Now nobody describes anything. You pass &lt;code&gt;--modules domain data&lt;/code&gt; and you get the same two modules I got, and the same two I'll get in November.&lt;/p&gt;
&lt;p&gt;Boring is the feature. Six months from now the newest module and the oldest one will still be the same shape, and that is worth more to me than a generator clever enough to surprise me.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Same jig, same tolerances, same car off the truck every race weekend. The lap time is where the interesting differences belong. 🏁&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27291/same-car-every-time-deterministic-feature-module-generation-in-kmp</guid>
<pubDate>Tue, 15 Sep 2026 14:00:00 +0000</pubDate>
</item>
<item>
<title>Two Racing Lines: Annotations and the DSL in NetFlow 0.7.0</title>
<link>https://coderlegion.com/27052/two-racing-lines-annotations-and-the-dsl-in-netflow-0-7-0</link>
<description>&lt;p&gt;I have been building NetFlow, my Kotlin Multiplatform networking library, since it was an Android-only thing called Communication. I wrote about that migration and why I took it on in &lt;a rel=&quot;nofollow&quot; href=&quot;/posts/netflow-part1&quot;&gt;Why I Took the Leap from Android-Only to Kotlin Multiplatform&lt;/a&gt;, so I will not repeat it here. Version 0.6 I added a &lt;code&gt;transform&lt;/code&gt; parameter. The compiler forces you to provide whenever your DTO and your domain model are different types. Forget it and the build fails. No runtime surprise, no half-mapped object reaching the UI.&lt;/p&gt;
&lt;p&gt;Then I started a new data layer in a side project, opened an empty file, and typed &lt;code&gt;@GET&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Into my own library. Which did not have annotations.&lt;/p&gt;
&lt;p&gt;That is a small, stupid moment, and it told me something I had been avoiding. NetFlow has three stars on GitHub and, as far as I know, no users outside my own projects, so I cannot tell you what evaluators said about it. I can tell you what my own hands did when I was not paying attention.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-reflex&quot;&gt;The reflex&lt;/h3&gt;
&lt;p&gt;Years of Retrofit built a motor pattern into every Android developer I know, including me. You want an endpoint, you write an interface, you put &lt;code&gt;@GET(&quot;users/{id}&quot;)&lt;/code&gt; on a method. It is not a decision. It is closer to reaching for the indicator stalk.&lt;/p&gt;
&lt;p&gt;Here is what fetching a list looked like in NetFlow 0.6:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
val todos = client.call {
    path = &quot;todos&quot;
    method = HttpMethod.Get
}.responseListFlow&amp;lt;TodoDto, Todo&amp;gt;(transform = { it.toModel() })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read that with fresh eyes. It says exactly what it does, it is one expression, and the mapping is compiler-checked. It is also nothing like what muscle memory reaches for.&lt;/p&gt;
&lt;p&gt;A developer evaluating a networking library gives it about ninety seconds of README. In that window, &quot;does this look like the thing I already know&quot; beats &quot;is this better&quot; almost every time. I don't think that is laziness. Familiarity is a real cost saving when you are picking one of five libraries that all do roughly the same job.&lt;/p&gt;
&lt;p&gt;So the 0.7.0 question was not &quot;annotations or DSL&quot;. It was: can I put a familiar entry on the front without weakening the thing behind it?&lt;/p&gt;
&lt;p&gt;What came out is a ladder with three rungs. The bottom rung is an annotated interface and covers most of what you write in a day. The middle rung annotates the request and hands the response back to you. The top rung is the DSL, unchanged, for the calls that need everything. You climb only as far as the call in front of you requires.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;rung-one-the-return-type-picks-the-strategy&quot;&gt;Rung one: the return type picks the strategy&lt;/h3&gt;
&lt;p&gt;The annotation layer lives in two new modules, &lt;code&gt;netflow-annotations&lt;/code&gt; and &lt;code&gt;netflow-ksp&lt;/code&gt;. You annotate an interface, KSP generates the implementation, and a factory extension hands it to you.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
@NetFlowApi
interface TodoApi {

    @GET(&quot;todos&quot;)
    suspend fun getTodos(@Query completed: Boolean?): AsyncState&amp;lt;List&amp;lt;TodoDto&amp;gt;&amp;gt;

    @GET(&quot;todos/{id}&quot;)
    fun observeTodo(@Path id: Int): Flow&amp;lt;ResultState&amp;lt;TodoDto&amp;gt;&amp;gt;

    @Headers(&quot;Accept: application/json&quot;, &quot;X-Client: netflow&quot;)
    @POST(&quot;todos&quot;)
    suspend fun create(@Body request: CreateTodoRequest): AsyncState&amp;lt;TodoDto&amp;gt;

    @DELETE(&quot;todos/{id}&quot;)
    suspend fun delete(@Path id: Int): AsyncState&amp;lt;Unit&amp;gt;

    @GET(&quot;todos&quot;)
    fun pagedTodos(): Flow&amp;lt;PagingData&amp;lt;TodoDto&amp;gt;&amp;gt;
}

val api = client.createTodoApi()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The part I want to point at is that there is no &lt;code&gt;@Strategy&lt;/code&gt; annotation anywhere. The return type does that job.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AsyncState&amp;lt;T&amp;gt;&lt;/code&gt; on a suspend function means a one-shot call. &lt;code&gt;Flow&amp;lt;ResultState&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; on a non-suspend function means an observable call with loading and error states. &lt;code&gt;Flow&amp;lt;PagingData&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; means a Paging 3 flow. &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; inside any of those routes to the list variants. The processor reads the shape and picks &lt;code&gt;responseAsync&lt;/code&gt;, &lt;code&gt;responseListAsync&lt;/code&gt;, &lt;code&gt;responseFlow&lt;/code&gt;, &lt;code&gt;responseListFlow&lt;/code&gt;, or &lt;code&gt;responsePaginated&lt;/code&gt; accordingly.&lt;/p&gt;
&lt;p&gt;This is not a clever trick, it is consistency. &lt;code&gt;Flow&amp;lt;ResultState&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; already self-describes in the hand-written DSL. Making the annotation layer read the same signal means there is one fact to learn instead of two.&lt;/p&gt;
&lt;p&gt;The processor is strict about mismatches, and the errors are compile-time:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Function 'getTodos' returns AsyncState and must be suspend.
Function 'observeTodo' path declares '{id}' but there is no @Path parameter named 'id'.
Function 'create' has more than one @Body; at most one @Body is allowed.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@Path&lt;/code&gt; and &lt;code&gt;@Query&lt;/code&gt; and &lt;code&gt;@Header&lt;/code&gt; take their wire name from the parameter name unless you override it, a nullable &lt;code&gt;@Query&lt;/code&gt; or &lt;code&gt;@Header&lt;/code&gt; is omitted from the request when it is null, and at most one &lt;code&gt;@Body&lt;/code&gt; is allowed per function.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-the-processor-actually-writes&quot;&gt;What the processor actually writes&lt;/h3&gt;
&lt;p&gt;This matters more than it sounds, so here is the generated file for two of those methods, copied out of &lt;code&gt;build/generated&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// _TodoApiImpl.kt, generated
internal class _TodoApiImpl(
  private val client: NetFlowClient,
) : TodoApi {
  override suspend fun getTodos(completed: Boolean?): AsyncState&amp;lt;List&amp;lt;TodoDto&amp;gt;&amp;gt; = client.call {
    method = HttpMethod.Get
    path = &quot;todos&quot;
    if (completed != null) parameter(&quot;completed&quot; to completed)
  }
  .responseListAsync&amp;lt;TodoDto&amp;gt;()

  override fun observeTodo(id: Int): Flow&amp;lt;ResultState&amp;lt;TodoDto&amp;gt;&amp;gt; = client.call {
    method = HttpMethod.Get
    path = &quot;todos/&quot; + id
  }
  .responseFlow&amp;lt;TodoDto&amp;gt;()
}

public fun NetFlowClient.createTodoApi(): TodoApi = _TodoApiImpl(this)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is the 0.6 DSL. Byte for byte, it's the code you would have written by hand. The annotations are not a parallel implementation, they are a text expansion into the existing one. Nothing new runs at runtime, there is no reflection, and there is no second code path to keep in sync when the DSL changes.&lt;/p&gt;
&lt;p&gt;It also means you can open the file and read it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-decision-that-matters-no-transform-in-annotations&quot;&gt;The decision that matters: no transform in annotations&lt;/h3&gt;
&lt;p&gt;Now the constraint that shaped everything else.&lt;/p&gt;
&lt;p&gt;An annotation cannot hold a lambda. Kotlin annotation parameters are limited to compile-time constants, so &lt;code&gt;transform = { it.toModel() }&lt;/code&gt; has nowhere to live. Neither does &lt;code&gt;onNetworkSuccess { db.insert(it) }&lt;/code&gt;, or &lt;code&gt;local { observe { db.todos() } }&lt;/code&gt;, or a paging &lt;code&gt;RemoteMediator&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The mapping problem has known workarounds and I looked at all of them. &lt;code&gt;@MappedBy(TodoMapper::class)&lt;/code&gt; pointing at an object with a &lt;code&gt;map&lt;/code&gt; function. A converter registry you populate at client construction. An interface the DTO implements. Every one of them takes a relationship the 0.6 compiler verifies and moves it somewhere the compiler cannot see, resolved by KSP at best and by a map lookup at worst. Forgetting a mapper would stop being a build error and start being a runtime cast exception on a device.&lt;/p&gt;
&lt;p&gt;I was not going to make the convenience layer less safe than the thing it is a convenience for. So annotated methods return the DTO. Full stop.&lt;/p&gt;
&lt;p&gt;The mapping moves one layer out, into the repository, using the &lt;code&gt;.map&lt;/code&gt; helpers NetFlow already ships. Their KDoc has said the same thing since 0.1: usually used in the repository to map the dto to the model.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
class TodoApiRepositoryImpl(client: NetFlowClient) {

    private val api = client.createTodoApi()

    suspend fun getTodos(): AsyncState&amp;lt;List&amp;lt;Todo&amp;gt;&amp;gt; =
        api.getTodos(completed = null).map { dtos -&amp;gt; dtos.map { it.toModel() } }

    fun observeTodo(id: Int): Flow&amp;lt;ResultState&amp;lt;Todo&amp;gt;&amp;gt; =
        api.observeTodo(id).map { state -&amp;gt; state.map { it.toModel() } }

    fun pagedTodos(): Flow&amp;lt;PagingData&amp;lt;Todo&amp;gt;&amp;gt; =
        api.pagedTodos().map { page -&amp;gt; page.map { it.toModel() } }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The mapping is still explicit, still typed, still checked by the compiler. It moved. It did not weaken.&lt;/p&gt;
&lt;p&gt;If there is one transferable idea in this whole article, it's that one. When you build a convenience layer over a stricter API, the layer is allowed to be less capable. It's not allowed to be less safe. The moment it is, every user of the convenience layer is quietly worse off than the users who ignored it, and you have built a trap with good ergonomics.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-paged-endpoint-is-one-line&quot;&gt;The paged endpoint is one line&lt;/h3&gt;
&lt;p&gt;If there is a single place where this layering pays for itself, it's paging.&lt;/p&gt;
&lt;p&gt;An annotation library that only speaks HTTP gets you as far as the DTO. After that you write a &lt;code&gt;PagingSource&lt;/code&gt;: a &lt;code&gt;load&lt;/code&gt; that does the key arithmetic, a &lt;code&gt;getRefreshKey&lt;/code&gt; that reads &lt;code&gt;state.anchorPosition&lt;/code&gt; and reaches for the closest page, a &lt;code&gt;LoadResult&lt;/code&gt; branch for success and one for failure, then a &lt;code&gt;Pager&lt;/code&gt; and a &lt;code&gt;PagingConfig&lt;/code&gt; to assemble it. That is fifty lines you have written before, and fifty more for the next paged endpoint, because none of this is about your data.&lt;/p&gt;
&lt;p&gt;In NetFlow you only need a &lt;code&gt;PagingData&lt;/code&gt; return type.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
@GET(&quot;todos&quot;)
fun pagedTodos(): Flow&amp;lt;PagingData&amp;lt;TodoDto&amp;gt;&amp;gt;

@Paginated(pageSize = 15)
@GET(&quot;todos&quot;)
fun pagedTodosSmall(): Flow&amp;lt;PagingData&amp;lt;TodoDto&amp;gt;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@Paginated&lt;/code&gt; is optional, and it carries only the two things: the query parameter name for the page number, and the page size. Leave it off and you get &lt;code&gt;page&lt;/code&gt; and 20.&lt;/p&gt;
&lt;p&gt;The DTO extends &lt;code&gt;PagingModel&lt;/code&gt;, which is where the page number and the last-updated timestamp live:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@Serializable
data class TodoDto(
    val userId: Int,
    val id: Int,
    val title: String,
    val completed: Boolean
) : PagingModel()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Forget it and the build stops with &lt;code&gt;Flow&amp;lt;PagingData&amp;lt;T&amp;gt;&amp;gt; requires T to extend PagingModel&lt;/code&gt;. Put &lt;code&gt;@Paginated&lt;/code&gt; on a method that returns something else and the build stops there too. Neither is a runtime discovery.&lt;/p&gt;
&lt;p&gt;The repository maps exactly like every other annotated call, and the ViewModel does what it always does:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
fun pagedTodos(): Flow&amp;lt;PagingData&amp;lt;Todo&amp;gt;&amp;gt; =
    api.pagedTodos().map { page -&amp;gt; page.map { it.toModel() } }

val todos = repository.pagedTodos().cachedIn(viewModelScope)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That flow is declared in &lt;code&gt;commonMain&lt;/code&gt;, and this is the part with no Ktorfit equivalent at all. On Android you collect it with &lt;code&gt;collectAsLazyPagingItems()&lt;/code&gt;. On iOS, &lt;code&gt;netflow-paging&lt;/code&gt; ships &lt;code&gt;PagingCollectionViewController&lt;/code&gt;, a Kotlin class exposing &lt;code&gt;loadStateFlow&lt;/code&gt;, &lt;code&gt;onPagesUpdatedFlow&lt;/code&gt;, &lt;code&gt;getItems()&lt;/code&gt; and &lt;code&gt;loadNextPage()&lt;/code&gt; to Swift through SKIE, so a SwiftUI list drives the same pager. One annotated method, both platforms, no per-platform paging code.&lt;/p&gt;
&lt;p&gt;Now the boundary. An annotated paged method is network-only. The generated call says so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// generated
.responsePaginated&amp;lt;TodoDto, TodoDto&amp;gt; {
  onlyApiCall = true
  defaultPageSize = 15
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Remote plus local paging needs a &lt;code&gt;PagingSource&lt;/code&gt; factory, an &lt;code&gt;insertAll&lt;/code&gt; block, and a timestamp lambda. Three lambdas. So it stays where lambdas live, and getting there is the next rung.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;rung-two-netflowcall&quot;&gt;Rung two: NetFlowCall&lt;/h3&gt;
&lt;p&gt;That leaves an obvious hole. What about an endpoint whose request is pure boilerplate, path and method and three query params, but whose response needs a local cache?&lt;/p&gt;
&lt;p&gt;Under a strict reading of rung one you drop back to the full DSL and hand-write the request too, which means the annotation layer is useless for exactly the calls that have the most typing in them.&lt;/p&gt;
&lt;p&gt;0.7.0 splits the call in half. An annotated method can return &lt;code&gt;NetFlowCall&lt;/code&gt;, which is the request with no response strategy attached:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
@GET(&quot;todos&quot;)
fun todosCall(): NetFlowCall
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The generated implementation stops at the request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// generated
override fun todosCall(): NetFlowCall = client.prepareCall {
  method = HttpMethod.Get
  path = &quot;todos&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the caller composes the response side with the whole DSL available, &lt;code&gt;transform&lt;/code&gt; requirement included:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
fun getTodos(): Flow&amp;lt;ResultState&amp;lt;List&amp;lt;Todo&amp;gt;&amp;gt;&amp;gt; =
    api.todosCall().responseListFlow&amp;lt;TodoDto, Todo&amp;gt;(transform = { it.toModel() }) {

        onNetworkSuccess { dtos -&amp;gt;
            database.todoQueries.transaction {
                dtos.forEach { database.todoQueries.insertTodo(it.toEntity()) }
            }
        }

        local(
            { observe { database.todoQueries.selectTodos() } },
            transform = { entities -&amp;gt; entities.map { it.toModel() } }
        )
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The annotation owns what annotations are good at: path, method, query parameters, headers, body. The repository owns what only a lambda can express.&lt;/p&gt;
&lt;p&gt;Because the response strategy now belongs to the caller, &lt;code&gt;@Wrapped&lt;/code&gt; on a &lt;code&gt;NetFlowCall&lt;/code&gt; method is a compile error, with a message that says why:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Function 'todosCall' returns NetFlowCall — @Wrapped is the caller's
response-strategy choice, not the interface's.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@Paginated&lt;/code&gt; fails the same way, since it requires a &lt;code&gt;Flow&amp;lt;PagingData&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; return type it will never see. I would rather refuse the annotation than silently pick a strategy on someone's behalf.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;prepareCall { }&lt;/code&gt; is public, so hand-written DSL users get the same split without touching annotations.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;rung-three-the-call-dsl&quot;&gt;Rung three: the call {} DSL&lt;/h3&gt;
&lt;p&gt;The top rung is unchanged. Remote plus local paging, &lt;code&gt;localQuery&lt;/code&gt; with its count and items and invalidation lambdas, retry policies with a &lt;code&gt;retryOn&lt;/code&gt; predicate, per-request headers computed at call time, &lt;code&gt;onlyLocalCall&lt;/code&gt; for offline reads. The paged call from two sections ago, with a database under it, looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
@OptIn(ExperimentalPagingApi::class)
override fun getTodos(): Flow&amp;lt;PagingData&amp;lt;Todo&amp;gt;&amp;gt; = client.call {
    path = &quot;todos&quot;
}.responsePaginated&amp;lt;TodoDto, Todo&amp;gt; {
    localSource(
        pagingSource = { TodoPagingSource(database) },
        transform = { it.toModel() }
    )

    deleteOnRefresh = false
    insertAll(transform = { it.toEntity() }) { todos -&amp;gt;
        database.todoQueries.transaction {
            database.todoQueries.deleteTodos()
            todos.forEach { database.todoQueries.insertTodo(it) }
        }
    }

    firstItemDatabase(
        itemDatabase = { database.todoQueries.getFirstTodo().executeAsOneOrNull() },
        timestamp = { it.lastUpdatedTimestamp }
    )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is no annotation on earth that expresses that, and I did not try to invent one. The top of the ladder is supposed to look like this.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;three-rungs-or-three-libraries&quot;&gt;Three rungs, or three libraries&lt;/h3&gt;
&lt;p&gt;The alternative to layering is what most projects already do. Ktorfit for the HTTP calls, Store or something hand-rolled for the cache, Paging wired up separately with its own &lt;code&gt;RemoteMediator&lt;/code&gt;. It works. I have shipped it.&lt;/p&gt;
&lt;p&gt;What it costs is coherence. Three libraries mean three sets of state types that don't compose, three failure models, and three test setups. Your Ktorfit call throws, your cache layer returns a sealed class, your paging layer emits &lt;code&gt;LoadState&lt;/code&gt;, and the repository is the place where you write the glue that reconciles all of it. Then you write a MockEngine for one, a fake for another, and an in-memory database for the third.&lt;/p&gt;
&lt;p&gt;In NetFlow the three rungs return the same &lt;code&gt;ResultState&lt;/code&gt;, &lt;code&gt;AsyncState&lt;/code&gt;, and &lt;code&gt;PagingData&lt;/code&gt; types. They run on the same &lt;code&gt;NetFlowClient&lt;/code&gt;. And they are tested with the same &lt;code&gt;MockNetFlowClient&lt;/code&gt;, which intercepts requests without any network at all:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@Test
fun `delete removes item from local database`() = runTest {
    val mockClient = MockNetFlowClient { _ -&amp;gt; NetFlowMockResponse.success() }
    val repository = TodoRepositoryImpl(mockClient, database)

    repository.deleteTodo(id = 1)

    mockClient.assertCalled(&quot;todos/1&quot;, HttpMethod.Delete)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That test doesn't care which rung the repository used. Swap &lt;code&gt;getTodos&lt;/code&gt; from an annotated method to a &lt;code&gt;call {}&lt;/code&gt; block and the test still passes, because the boundary is the client, not the call style.&lt;/p&gt;
&lt;p&gt;Now the honest part. If you just want to use annotations, Ktorfit beats this comfortably. It has years of production use, multipart and form-data, streaming, response converters, a much larger vocabulary, and a community that has already hit the bugs you are about to hit. NetFlow 0.7.0 has a first pass and a roadmap item that still says multipart. If HTTP is genuinely all you need, use Ktorfit.&lt;/p&gt;
&lt;p&gt;The exception is the one I spent a whole section on. No annotation layer hands you a &lt;code&gt;Flow&amp;lt;PagingData&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; from &lt;code&gt;commonMain&lt;/code&gt;, because that requires the paging integration to already be part of the same library. That is not me out-annotating Ktorfit, it's the stack underneath the annotations showing through, which is the only bet I am actually making.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;generating-into-commonmain-with-ksp&quot;&gt;Generating into commonMain with KSP&lt;/h3&gt;
&lt;p&gt;This is the part I found least documented, so here it is in full.&lt;/p&gt;
&lt;p&gt;The processor emits one file per annotated interface into the common metadata source set, and every target then compiles that file as ordinary common code. On the consumer side it is four pieces of wiring:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// build.gradle.kts
plugins {
    alias(libs.plugins.ksp)
}

kotlin {
    sourceSets {
        commonMain {
            kotlin.srcDir(&quot;build/generated/ksp/metadata/commonMain/kotlin&quot;)
        }
        commonMain.dependencies {
            implementation(&quot;io.github.kmpbits:netflow-annotations:&amp;lt;latest_version&amp;gt;&quot;)
        }
    }
}

dependencies {
    add(&quot;kspCommonMainMetadata&quot;, &quot;io.github.kmpbits:netflow-ksp:&amp;lt;latest_version&amp;gt;&quot;)
}

tasks.withType&amp;lt;KotlinCompilationTask&amp;lt;*&amp;gt;&amp;gt;().configureEach {
    if (name != &quot;kspCommonMainKotlinMetadata&quot;) {
        dependsOn(&quot;kspCommonMainKotlinMetadata&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;srcDir&lt;/code&gt; line and the &lt;code&gt;dependsOn&lt;/code&gt; block are the two that bite. Without &lt;code&gt;srcDir&lt;/code&gt;, the generated file exists on disk and no source set can see it. Without the task dependency, your Android compilation happily starts before KSP has produced anything, and you get an unresolved reference to &lt;code&gt;createTodoApi&lt;/code&gt; that disappears on the second build. That intermittent-failure flavour is the worst kind to debug, which is exactly why it is worth ten lines of Gradle.&lt;/p&gt;
&lt;p&gt;One detail worth mention: &lt;code&gt;netflow-ksp&lt;/code&gt; has no dependency on &lt;code&gt;netflow-paging&lt;/code&gt;. The processor emits &lt;code&gt;responsePaginated&lt;/code&gt; as a fully qualified name string and lets the consumer's own classpath resolve it. If you never return &lt;code&gt;Flow&amp;lt;PagingData&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt;, you never pull in Paging 3, and the annotation module stays a pure Kotlin Multiplatform artifact with no Android-flavoured transitive weight.&lt;/p&gt;
&lt;p&gt;I picked KSP over a compiler plugin for one reason: the output is a plain &lt;code&gt;.kt&lt;/code&gt; file you can open. When a generated call sends the wrong query parameter, you go look at the generated line and see it. A compiler plugin gives you IR, a stack trace, and a bad afternoon. Ktorfit itself ran on KSP for years and only moved to a compiler-plugin-only model after it had the user base to absorb that opacity. For a library at 0.7.0 with three stars, readable output is worth more than whatever the plugin route buys.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-line-i-hold-1&quot;&gt;The line I hold&lt;/h3&gt;
&lt;p&gt;Adding a familiar surface to an unfamiliar API is not a compromise, as long as the surface refuses to lie about what is underneath it. Annotations in NetFlow do not wrap the DSL, they generate it, they return the same types, they run on the same client, they break at compile time when you ask them for something they cannot do, and they stop at the exact line where a lambda becomes necessary. That stopping point is the design, not a gap in it.&lt;/p&gt;
&lt;p&gt;A layer that quietly did less checking so it could cover more cases would be a worse library with a better README.&lt;/p&gt;
&lt;p&gt;This is 0.7.0, and the honest framing is that I have no external users yet. The annotation surface may well shift in 0.8 or 0.9 once somebody who is not me tries to model an API with it. Read the code samples here as 0.7.0, not as a promise.&lt;/p&gt;
&lt;p&gt;But the shape I would defend regardless of version: put the familiar entry on the front, keep the harder API reachable from it without changing cars, and be loud about where one ends and the other begins.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Pit lane or full circuit, it is the same car. 🏁&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The library is available on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/kmpbits/netflow&quot;&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27052/two-racing-lines-annotations-and-the-dsl-in-netflow-0-7-0</guid>
<pubDate>Mon, 14 Sep 2026 08:00:00 +0000</pubDate>
</item>
<item>
<title>Building High-Performance Text Editors in Kotlin Multiplatform with Krope</title>
<link>https://coderlegion.com/23397/building-high-performance-text-editors-in-kotlin-multiplatform-with-krope</link>
<description>&lt;p&gt;If you have ever tried building a text editor, an IDE, or a collaborative document tool, you quickly run into a fundamental wall: &lt;strong&gt;how do you store the text?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;At first glance, a standard &lt;code&gt;String&lt;/code&gt; seems fine. But Strings are immutable; changing a single character in a 1MB file requires allocating a brand new 1MB array. So, you move to &lt;code&gt;StringBuilder&lt;/code&gt;. It’s mutable and fast for simple appends, but inserting or deleting text in the middle of a massive document still requires shifting hundreds of thousands of characters in memory — an O(N) operation that causes UI stuttering as your document grows.&lt;/p&gt;
&lt;p&gt;This is where the &lt;strong&gt;Rope&lt;/strong&gt; data structure comes in.&lt;/p&gt;
&lt;p&gt;Today, I want to introduce &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/numq/krope&quot;&gt;&lt;strong&gt;Krope&lt;/strong&gt;&lt;/a&gt;, a high-performance, memory-safe, and reactive Rope library built entirely in Kotlin Multiplatform. Whether you are targeting JVM, Android, iOS, or WASM, Krope provides the engine you need to build next-generation text editing tools.&lt;/p&gt;
&lt;h3 id=&quot;what-is-a-rope-&quot;&gt;What is a Rope?&lt;/h3&gt;
&lt;p&gt;Instead of storing text as a single massive, contiguous array of characters, a &lt;a rel=&quot;nofollow&quot; href=&quot;https://en.wikipedia.org/wiki/Rope_(data_structure)&quot;&gt;Rope&lt;/a&gt; stores text as a balanced binary tree (in Krope’s case, utilizing a Red-Black tree variant). The leaves of the tree contain short chunks of strings.&lt;/p&gt;
&lt;p&gt;When you insert text in the middle of a document, a Rope doesn’t copy the whole string. Instead, it splits a leaf node and inserts a new branch. This gives operations like insertion, deletion, and replacement a time complexity of O(log N).&lt;/p&gt;
&lt;p&gt;Furthermore, Krope implements &lt;strong&gt;structural sharing&lt;/strong&gt;. When a modification occurs, Krope doesn’t mutate the existing tree; it returns a &lt;em&gt;new&lt;/em&gt; tree that shares the untouched branches with the old one. This provides safe concurrent access, cheap undo/redo history.&lt;/p&gt;
&lt;h3 id=&quot;why-i-built-krope&quot;&gt;Why I Built Krope&lt;/h3&gt;
&lt;p&gt;The idea for Krope was born while building &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/numq/haskcore&quot;&gt;haskcore&lt;/a&gt; — a full-featured IDE for Haskell written in Compose Desktop. It uses Skia for rendering, Tree-sitter for incremental syntax parsing, and the Language Server Protocol for code intelligence. It’s a real editor with 40+ modules, handling everything from syntax highlighting to code completion and project-wide diagnostics.&lt;/p&gt;
&lt;p&gt;Very quickly, I hit a wall: Kotlin’s standard &lt;code&gt;StringBuilder&lt;/code&gt; couldn't keep up. Editing large Haskell modules meant shifting massive text buffers on every keystroke, causing visible lag during syntax highlighting and LSP synchronization. I needed something better — and the rope data structure, battle-tested in editors like Emacs and Vim since the 90s, was the obvious answer.&lt;/p&gt;
&lt;p&gt;But Kotlin Multiplatform lacked a modern, reactive, and fully-featured rope implementation. So I built Krope to solve the specific pain points I encountered:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cross-Platform Consistency: haskcore targets the desktop, but the same text engine should work on Android, iOS, and WASM for future ports or companion apps.&lt;/li&gt;
&lt;li&gt;Reactive by Design: Compose Multiplatform thrives on reactive state. Krope exposes every edit as a &lt;code&gt;SharedFlow&lt;/code&gt; event and every document state as a &lt;code&gt;StateFlow&lt;/code&gt;, making UI integration seamless.&lt;/li&gt;
&lt;li&gt;Byte-Level Encoding Awareness: LSP servers speak in byte offsets, not character positions. Krope natively tracks UTF-8, UTF-16, and UTF-32, giving you efficient access to byte coordinates backed by an LRU cache.&lt;/li&gt;
&lt;li&gt;Functional &amp;amp; Safe: Invalid operations shouldn’t crash your editor. Krope uses an &lt;code&gt;Either&amp;lt;Throwable, Result&amp;gt;&lt;/code&gt; pattern, so out-of-bounds inserts return errors you can handle gracefully — critical when processing remote edits from LSP or collaborative peers.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;inside-the-krope-api&quot;&gt;Inside the Krope API&lt;/h3&gt;
&lt;p&gt;Krope separates the underlying data structure from the editor API. The primary interface you interact with is the &lt;code&gt;TextBuffer&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here is how simple it is to get started:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;import io.github.numq.krope.core.Encoding
import io.github.numq.krope.text.*
import kotlinx.coroutines.launch

// 1. Initialize the buffer
val factory = RopeTextBufferFactory()
val buffer = factory.create(
    text = &quot;Hello World&quot;,
    encoding = Encoding.UTF8,
    lineEnding = TextLineEnding.LF,
    enablePooling = true // Enables internal string interning to save memory
).fold(
    onLeft = { error -&amp;gt; throw error },
    onRight = { it }
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-reactive-paradigm&quot;&gt;The Reactive Paradigm&lt;/h3&gt;
&lt;p&gt;In a standard text editor, the UI needs to know exactly &lt;em&gt;what&lt;/em&gt; changed to update the screen or send a diff over the network for collaborative editing. Krope exposes two flows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;snapshot: StateFlow&amp;lt;TextSnapshot&amp;gt;&lt;/code&gt;: Represents the immutable current state of the document.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;data: SharedFlow&amp;lt;TextEdit.Data&amp;gt;&lt;/code&gt;: Emits granular events whenever the text changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// Observe all changes in real-time
launch {
    buffer.data.collect { editData -&amp;gt;
        when (editData) {
            is TextEdit.Data.Single.Insert -&amp;gt; 
                println(&quot;Inserted '${editData.insertedText}' at ${editData.startPosition}&quot;)
            is TextEdit.Data.Single.Delete -&amp;gt; 
                println(&quot;Deleted text ending at ${editData.oldEndPosition}&quot;)
            is TextEdit.Data.Single.Replace -&amp;gt; 
                println(&quot;Replaced '${editData.oldText}' with '${editData.newText}'&quot;)
            is TextEdit.Data.Batch -&amp;gt; 
                println(&quot;Batch processed ${editData.singles.size} operations atomically&quot;)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;line-and-column-awareness&quot;&gt;Line and Column Awareness&lt;/h3&gt;
&lt;p&gt;Most ropes just deal with raw character offsets. Krope natively understands &lt;code&gt;Lines&lt;/code&gt; and &lt;code&gt;Columns&lt;/code&gt; through &lt;code&gt;TextPosition&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// &quot;Hello Beautiful World&quot;
buffer.insert(TextPosition(line = 0, column = 5), &quot; Beautiful&quot;) 
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// Querying the snapshot is instantaneous thanks to cached tree node metadata
val snapshot = buffer.snapshot.value
println(&quot;Total Lines: ${snapshot.lines}&quot;)
println(&quot;Max Line Length: ${snapshot.maxLineLength}&quot;)

// Get byte offset for an LSP (Language Server Protocol)
val byteOffset = snapshot.getBytePosition(TextPosition(line = 0, column = 6))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;atomic-batch-operations&quot;&gt;Atomic Batch Operations&lt;/h3&gt;
&lt;p&gt;When you refactor code (like renaming a variable that appears 10 times in a file), you don’t want to trigger 10 separate UI redraws or clutter the undo stack. Krope supports &lt;code&gt;withBatch&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;buffer.withBatch { batch -&amp;gt;
    batch.replace(TextRange(TextPosition(0, 39), TextPosition(0, 42)), &quot;vatAmount&quot;)
    batch.replace(TextRange(TextPosition(1, 19), TextPosition(1, 22)), &quot;vatAmount&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This applies all edits internally, rebalances the tree &lt;em&gt;once&lt;/em&gt;, and emits a single &lt;code&gt;TextEdit.Data.Batch&lt;/code&gt; event to your listeners.&lt;/p&gt;
&lt;h3 id=&quot;performance-the-100-megabyte-test&quot;&gt;Performance: The 100 Megabyte Test&lt;/h3&gt;
&lt;p&gt;To answer the age-old question - &lt;em&gt;&quot;How does it perform with very large files?&quot;&lt;/em&gt; - I ran JMH benchmarks on the JVM comparing Krope to &lt;code&gt;StringBuilder&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;We simulated the &quot;Real Editor Scenario&quot;: &lt;strong&gt;100 consecutive edits&lt;/strong&gt; (insertions) occurring right in the middle of a document. We scaled the document size from 100,000 characters all the way to &lt;strong&gt;100,000,000 characters (approximately 200 MB of text)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Here are the results (in milliseconds per 100 operations):&lt;/p&gt;
&lt;p&gt;100,000 symbols&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Krope: &lt;strong&gt;~0.05 ms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;StringBuilder: &lt;strong&gt;~0.16 ms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Krope Advantage: &lt;strong&gt;~3x faster&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;1,000,000 symbols&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Krope: &lt;strong&gt;~0.19 ms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;StringBuilder: &lt;strong&gt;~1.74 ms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Krope Advantage: &lt;strong&gt;~9x faster&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;10,000,000 symbols&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Krope: &lt;strong&gt;~2.07 ms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;StringBuilder: &lt;strong&gt;~17.86 ms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Krope Advantage: &lt;strong&gt;~8.5x faster&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;100,000,000 symbols&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Krope: &lt;strong&gt;~19.32 ms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;StringBuilder: &lt;strong&gt;~186.41 ms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Krope Advantage: &lt;strong&gt;~9.6x faster&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;why-does-stringbuilder-choke-&quot;&gt;Why does StringBuilder choke?&lt;/h3&gt;
&lt;p&gt;Look at how the &lt;code&gt;StringBuilder&lt;/code&gt; time multiplies by 10 every time the file size multiplies by 10. This is the curse of O(N) complexity. When you insert a single character in the middle of a 200MB &lt;code&gt;StringBuilder&lt;/code&gt;, Java has to use &lt;code&gt;System.arraycopy&lt;/code&gt; to physically shift 100MB of data in RAM to make room. Doing this 100 times forces the CPU to move &lt;strong&gt;10 gigabytes&lt;/strong&gt; of memory. This is exactly what causes UI thread stuttering and input lag in text editors.&lt;/p&gt;
&lt;h3 id=&quot;why-does-krope-win-&quot;&gt;Why does Krope win?&lt;/h3&gt;
&lt;p&gt;Because of &lt;strong&gt;structural sharing&lt;/strong&gt; and its Red-Black tree foundation. When making 100 edits, Krope doesn't copy 100MB of arrays. It simply splits a small leaf node, updates a few pointers up to the root, and does a lightning-fast O(log N) tree rebalance.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Note: In the benchmark above, the ~19.32 ms for Krope actually included the time to parse the 200MB string into a Rope tree from scratch on every iteration! The edits themselves are nearly instantaneous).&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;In a real-world typing or refactoring scenario, Krope maintains incredibly flat, predictable latency regardless of whether your file is 10 Kilobytes or 200 Megabytes, keeping your UI perfectly smooth while drastically reducing heap allocations.&lt;/p&gt;
&lt;h3 id=&quot;get-involved&quot;&gt;Get Involved&lt;/h3&gt;
&lt;p&gt;If you are building text-heavy applications in Compose Multiplatform, writing a code editor, or experimenting with CRDTs for collaborative editing, give Krope a try.&lt;/p&gt;
&lt;p&gt;The architecture is highly modular, separating the core data structures from the TextBuffer API, and includes built-in support for &lt;code&gt;kotlinx.serialization&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Check out the source code, play with the examples, and star the repository on GitHub!&lt;/p&gt;
&lt;p&gt;🔗 &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/numq/krope&quot;&gt;&lt;strong&gt;GitHub Repository: Krope&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23397/building-high-performance-text-editors-in-kotlin-multiplatform-with-krope</guid>
<pubDate>Fri, 24 Jul 2026 13:03:31 +0000</pubDate>
</item>
<item>
<title>Kotlin 2.4.0 Released</title>
<link>https://coderlegion.com/20530/kotlin-2-4-0-released</link>
<description>&lt;p&gt;JetBrains has released Kotlin 2.4.0. The update includes stable language features, standard library additions, Kotlin/Native changes for Swift interoperability, Kotlin/Wasm and Kotlin/JS improvements, and build tool updates for Gradle and Maven.&lt;/p&gt;
&lt;p&gt;The release is included in the latest versions of IntelliJ IDEA and Android Studio. Projects can update by setting the Kotlin version to 2.4.0 in their build scripts.&lt;/p&gt;
&lt;h3 id=&quot;language-and-standard-library&quot;&gt;Language and Standard Library&lt;/h3&gt;
&lt;p&gt;Several language features that were previously experimental are now stable, including context parameters, explicit backing fields, the @all meta-target for properties, and new defaulting rules for annotation use-site targets.&lt;/p&gt;
&lt;p&gt;The common standard library now has a stable kotlin.uuid.Uuid API. Kotlin 2.4.0 also adds sorted-order checks such as .isSorted() and .isSortedBy(), plus JVM extensions for converting unsigned integers to BigInteger.&lt;/p&gt;
&lt;p&gt;On Kotlin/JVM, the compiler can generate Java 26 bytecode, and annotations in Kotlin metadata are now enabled by default.&lt;/p&gt;
&lt;h3 id=&quot;kotlin-native-and-kmp&quot;&gt;Kotlin/Native and KMP&lt;/h3&gt;
&lt;p&gt;For Kotlin Multiplatform projects, the main changes are in Kotlin/Native. Swift export is now Alpha and has improved concurrency support: Kotlin suspend functions are exported as Swift async counterparts, and kotlinx.coroutines flows can be exported to Swift as AsyncSequence.&lt;/p&gt;
&lt;p&gt;KMP projects can also declare Swift packages as dependencies for iOS apps directly in Gradle configuration. JetBrains also provides migration support for projects that currently rely on CocoaPods dependencies.&lt;/p&gt;
&lt;p&gt;Kotlin/Native now supports Xcode 26.4, updates LLVM to version 21, and enables the concurrent mark and sweep garbage collector by default. The default minimum supported Apple target versions have also been raised to iOS and tvOS 15.0, macOS 12.0, and watchOS 8.0.&lt;/p&gt;
&lt;h3 id=&quot;wasm-js-and-build-tools&quot;&gt;Wasm, JS, and Build Tools&lt;/h3&gt;
&lt;p&gt;Kotlin/Wasm incremental compilation is now stable and enabled by default. Kotlin 2.4.0 also introduces experimental support for the WebAssembly Component Model.&lt;/p&gt;
&lt;p&gt;Kotlin/JS improves JavaScript and TypeScript export with support for value class export and ES2015 features when inlining JavaScript code.&lt;/p&gt;
&lt;p&gt;On the build side, Kotlin 2.4.0 is compatible with Gradle 9.5.0. Maven builds now support automatic alignment between Java and JVM target versions.&lt;/p&gt;
&lt;p&gt;Official announcement:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.jetbrains.com/kotlin/2026/06/kotlin-2-4-0-released/&quot;&gt;https://blog.jetbrains.com/kotlin/2026/06/kotlin-2-4-0-released/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Detailed release notes:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://kotlinlang.org/docs/whatsnew24.html&quot;&gt;https://kotlinlang.org/docs/whatsnew24.html&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20530/kotlin-2-4-0-released</guid>
<pubDate>Sat, 13 Jun 2026 19:31:15 +0000</pubDate>
</item>
<item>
<title>Drop the Clutch: Three Metro DI Patterns Every KMP Developer Should Know - Part 2</title>
<link>https://coderlegion.com/19771/drop-the-clutch-three-metro-di-patterns-every-kmp-developer-should-know-part-2</link>
<description>&lt;p&gt;In &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/19769/drop-the-clutch-three-metro-di-patterns-every-kmp-developer-should-know-part-1&quot;&gt;Part 1&lt;/a&gt;, I covered Metro's fundamentals: how it differs from Koin Annotations and Dagger, the basic setup, and the &lt;code&gt;@ContributesBinding&lt;/code&gt; pattern for everyday features. This part picks up where that left off, with two patterns that push past the basics — platform-specific graph splitting and scoped lifetimes with runtime values.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;feature-2-notification-permission&quot;&gt;Feature 2: Notification permission&lt;/h3&gt;
&lt;p&gt;This is where Metro in a multiplatform project gets genuinely tricky, and where the constraint that makes it correct becomes obvious.&lt;/p&gt;
&lt;p&gt;On Android, requesting notification permission requires &lt;code&gt;Context&lt;/code&gt;, which goes to &lt;code&gt;ActivityCompat.requestPermissions&lt;/code&gt;. On iOS, you use &lt;code&gt;UNUserNotificationCenter&lt;/code&gt;. Neither type exists in &lt;code&gt;commonMain&lt;/code&gt;. The interface for the provider lives there, but the implementations are entirely platform-specific. If you want a deep dive into cross-platform notifications with KMP, I covered the full setup in a &lt;a rel=&quot;nofollow&quot; href=&quot;/posts/notifications-kmp&quot;&gt;previous article&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The instinct is to put &lt;code&gt;@DependencyGraph&lt;/code&gt; on the common interface and let platform graphs extend it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// WRONG: fails to compile on the iOS target
// commonMain/di/AppGraph.kt
@DependencyGraph(scope = AppScope::class)
interface AppGraph {
    @DependencyGraph.Factory
    fun interface Factory {
        fun create(@Provides context: Context): AppGraph // Context is an Android type
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The iOS compiler doesn't know what &lt;code&gt;Context&lt;/code&gt; is. The build fails with an unresolved reference. After that error, we can try &lt;code&gt;@GraphExtension&lt;/code&gt;. Metro provides it for extending an existing graph, but &lt;code&gt;@GraphExtension.Factory&lt;/code&gt; is a different annotation from &lt;code&gt;@DependencyGraph.Factory&lt;/code&gt;, and &lt;code&gt;createGraphFactory&lt;/code&gt; only accepts the second one. You end up with a graph you can't instantiate.&lt;/p&gt;
&lt;p&gt;I went through both of those dead ends before the correct pattern became clear: the common interface is just a plain Kotlin interface with no Metro annotations. The &lt;code&gt;@DependencyGraph&lt;/code&gt; goes only on the platform-specific implementations.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/di/RootGraph.kt
import dev.zacsweers.metrox.viewmodel.ViewModelGraph

// Plain Kotlin interface, no @DependencyGraph
interface RootGraph : ViewModelGraph {
    val platformNotificationProvider: PlatformNotificationProvider
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// androidMain/di/AndroidRootGraph.kt
import dev.zacsweers.metro.DependencyGraph
import dev.zacsweers.metro.Provides

@DependencyGraph(scope = AppScope::class)
interface AndroidRootGraph : RootGraph {

    @DependencyGraph.Factory
    fun interface Factory {
        fun create(@Provides context: Context): AndroidRootGraph
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// iosMain/di/IosRootGraph.kt
import dev.zacsweers.metro.DependencyGraph

@DependencyGraph(scope = AppScope::class)
interface IosRootGraph : RootGraph
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Creating the graph on each platform:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// androidMain/App.kt
class App : Application() {
    val graph: RootGraph by lazy {
        createGraphFactory&amp;lt;AndroidRootGraph.Factory&amp;gt;().create(applicationContext)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// iosMain/MainViewController.kt
fun MainViewController() = ComposeUIViewController {
    val graph = remember { createGraph&amp;lt;IosRootGraph&amp;gt;() }
    App(graph)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;createGraph&amp;lt;T&amp;gt;()&lt;/code&gt; expects &lt;code&gt;T&lt;/code&gt; to be a &lt;code&gt;@DependencyGraph&lt;/code&gt; with no required external parameters. &lt;code&gt;createGraphFactory&amp;lt;F&amp;gt;()&lt;/code&gt; expects &lt;code&gt;F&lt;/code&gt; to be a &lt;code&gt;@DependencyGraph.Factory&lt;/code&gt; inside a graph that needs those parameters. Both return a &lt;code&gt;RootGraph&lt;/code&gt;, which is what the shared &lt;code&gt;App()&lt;/code&gt; composable receives. The composable never needs to know which platform it's running on.&lt;/p&gt;
&lt;h4 id=&quot;platform-implementations&quot;&gt;Platform implementations&lt;/h4&gt;
&lt;p&gt;The notification provider expect class lives in common:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/core/domain/PlatformNotificationProvider.kt
import kotlinx.coroutines.flow.StateFlow

expect class PlatformNotificationProvider {
    val permissionGranted: StateFlow&amp;lt;Boolean&amp;gt;
    fun requestPermission()
    fun observeNotificationServicePermission()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Android implementation uses &lt;code&gt;Context&lt;/code&gt; and an &lt;code&gt;ActivityHolder&lt;/code&gt;, a wrapper that &lt;code&gt;MainActivity&lt;/code&gt; updates on &lt;code&gt;onCreate&lt;/code&gt;/&lt;code&gt;onDestroy&lt;/code&gt; so DI-managed classes can reach the current &lt;code&gt;Activity&lt;/code&gt; without holding a direct reference to it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// androidMain/feature/notifications/data/PlatformNotificationProviderImpl.kt
@Inject
@SingleIn(AppScope::class)
actual class PlatformNotificationProvider(
    private val context: Context,
    private val activityHolder: ActivityHolder
) {
    companion object {
        const val REQUEST_CODE_NOTIFICATIONS = 1001
    }

    private val _permissionGranted = MutableStateFlow(false)
    actual val permissionGranted: StateFlow&amp;lt;Boolean&amp;gt; = _permissionGranted

    actual fun requestPermission() {
        val activity = activityHolder.get() ?: return
        ActivityCompat.requestPermissions(
            activity,
            arrayOf(Manifest.permission.POST_NOTIFICATIONS),
            REQUEST_CODE_NOTIFICATIONS
        )
    }

    actual fun observeNotificationServicePermission() {
        _permissionGranted.value = ContextCompat.checkSelfPermission(
            context, Manifest.permission.POST_NOTIFICATIONS
        ) == PackageManager.PERMISSION_GRANTED
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Android &lt;/th&gt;&lt;th&gt;iOS&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;img src=&quot;/assets/drop-the-clutch-notifications-android.gif&quot; alt=&quot;Notification permission dialog being requested and granted&quot;&gt; &lt;/td&gt;&lt;td&gt;&lt;img src=&quot;/assets/drop-the-clutch-notifications-ios.gif&quot; alt=&quot;Notification permission dialog being requested and granted&quot;&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Android doesn't deliver permission results to DI-managed classes. They come back to &lt;code&gt;Activity.onRequestPermissionsResult&lt;/code&gt;. The pattern that works: &lt;code&gt;MainActivity&lt;/code&gt; calls &lt;code&gt;observeNotificationServicePermission()&lt;/code&gt; in the callback, the provider re-reads the system state, and the &lt;code&gt;MutableStateFlow&lt;/code&gt; updates. Everything else observes the flow.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// androidMain/MainActivity.kt
override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array&amp;lt;out String?&amp;gt;,
    grantResults: IntArray,
    deviceId: Int
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults, deviceId)
    if (requestCode == PlatformNotificationProviderImpl.REQUEST_CODE_NOTIFICATIONS) {
        graph.platformNotificationProvider.observeNotificationServicePermission()
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The iOS implementation uses &lt;code&gt;UNUserNotificationCenter&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The error you get if you annotate the common graph isn't Metro being fussy. It's the compiler catching a design that would break the iOS build entirely. The plain interface isn't a workaround. It's the correct model.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;feature-3-chat-room&quot;&gt;Feature 3: Chat room&lt;/h3&gt;
&lt;p&gt;This is the pattern I wanted to understand before committing to Metro seriously. The first two features use &lt;code&gt;AppScope&lt;/code&gt; for everything, which makes sense: repositories and providers live for the app's lifetime. But not everything does.&lt;/p&gt;
&lt;p&gt;A WebSocket isn't a data object. It connects on creation and needs to be explicitly closed. Keeping it in &lt;code&gt;AppScope&lt;/code&gt; means the socket stays open for the entire app lifetime, even when the user is on a completely different screen. That's almost never what you want for a real-time connection.&lt;/p&gt;
&lt;p&gt;A scope is a lifetime, not a namespace. The &lt;code&gt;ChatScope&lt;/code&gt; exists because the socket needs to be opened when the chat screen opens and closed when it leaves. And because every chat room is a different conversation, there's a second problem to solve: the conversation ID is only known at runtime, when the user taps a row. Metro needs to receive it and make it available to anything in the scope.&lt;/p&gt;
&lt;p&gt;Here's how the two scopes relate in the demo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AppScope  (AndroidRootGraph / IosRootGraph)
├── ConversationRepository       singleton, lives for the app's lifetime
├── PlatformNotificationProvider singleton, lives for the app's lifetime
├── AppViewModelFactory
│
└── ChatScope  (ChatGraph) ──── created via ChatGraph.Factory at navigation time
    ├── ConversationId           runtime value, injected through the factory parameter
    ├── ChatSocket               singleton within this scope
    └── ChatViewModelFactory
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ChatScope&lt;/code&gt; is nested inside &lt;code&gt;AppScope&lt;/code&gt; and inherits all of its bindings. &lt;code&gt;ChatGraph&lt;/code&gt; uses &lt;code&gt;@GraphExtension&lt;/code&gt; for exactly that reason. Anything in &lt;code&gt;ChatScope&lt;/code&gt; can ask for &lt;code&gt;ConversationRepository&lt;/code&gt; or &lt;code&gt;PlatformNotificationProvider&lt;/code&gt; and Metro will resolve them from the parent graph. The reverse isn't true: &lt;code&gt;AppScope&lt;/code&gt; knows nothing about &lt;code&gt;ChatSocket&lt;/code&gt; or &lt;code&gt;ConversationId&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;the-scope-and-the-socket&quot;&gt;The scope and the socket&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/di/ChatScope.kt
import dev.zacsweers.metro.Scope

@Scope
annotation class ChatScope
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The conversation ID is a runtime value, so it gets a proper type:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/feature/chat/domain/ConversationId.kt
@JvmInline
value class ConversationId(val value: String)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A value class, different from a raw &lt;code&gt;String&lt;/code&gt;, keeps the binding unambiguous. Metro identifies bindings by type, so &lt;code&gt;ConversationId&lt;/code&gt; and &lt;code&gt;String&lt;/code&gt; are different things in the graph.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/feature/chat/data/ChatSocket.kt
@Inject
@SingleIn(ChatScope::class)
class ChatSocket(val conversationId: ConversationId) {

    private val _messages = MutableStateFlow&amp;lt;List&amp;lt;ChatMessage&amp;gt;&amp;gt;(emptyList())
    val messages: StateFlow&amp;lt;List&amp;lt;ChatMessage&amp;gt;&amp;gt; = _messages

    private val _isTyping = MutableStateFlow(false)
    val isTyping: StateFlow&amp;lt;Boolean&amp;gt; = _isTyping

    private var job: Job? = null

    init {
        job = CoroutineScope(Dispatchers.Default).launch {
            var lap = 1
            while (true) {
                delay(2_000)
                _isTyping.value = true
                delay(1_000)
                _isTyping.value = false
                _messages.update {
                    it + ChatMessage(sender = &quot;Engineer&quot;, text = &quot;Lap $lap: pace looks good.&quot;)
                }
                lap++
            }
        }
    }

    fun close() {
        job?.cancel()
        job = null
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ChatSocket&lt;/code&gt; declares &lt;code&gt;ConversationId&lt;/code&gt; as a constructor parameter. Metro will inject it — no special annotation needed on the parameter itself. The binding just has to exist somewhere in the scope, and the factory is where it comes from.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@SingleIn(ChatScope::class)&lt;/code&gt; means one &lt;code&gt;ChatSocket&lt;/code&gt; per &lt;code&gt;ChatGraph&lt;/code&gt;. Not one per app, one per chat session. Navigate away and come back, and you get a fresh socket with its own conversation ID.&lt;/p&gt;
&lt;h4 id=&quot;the-graph-extension&quot;&gt;The graph extension&lt;/h4&gt;
&lt;p&gt;The chat graph uses &lt;code&gt;@GraphExtension&lt;/code&gt; rather than &lt;code&gt;@DependencyGraph&lt;/code&gt;. The difference is that &lt;code&gt;@GraphExtension&lt;/code&gt; extends an existing parent graph and inherits all of its bindings. &lt;code&gt;@DependencyGraph&lt;/code&gt; creates a standalone graph that knows nothing about its caller.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/di/ChatGraph.kt
import dev.zacsweers.metro.GraphExtension
import dev.zacsweers.metro.Provides
import dev.zacsweers.metrox.viewmodel.ViewModelGraph

@GraphExtension(scope = ChatScope::class)
interface ChatGraph : ViewModelGraph {
    val chatSocket: ChatSocket

    @ContributesTo(AppScope::class)
    @GraphExtension.Factory
    fun interface Factory {
        fun create(@Provides conversationId: ConversationId): ChatGraph
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@Provides&lt;/code&gt; on the factory parameter is Metro's answer to runtime injection. When &lt;code&gt;create(conversationId)&lt;/code&gt; is called, Metro takes that value and makes it available as a binding for the entire &lt;code&gt;ChatScope&lt;/code&gt;. &lt;code&gt;ChatSocket&lt;/code&gt; asks for &lt;code&gt;ConversationId&lt;/code&gt; in its constructor; Metro delivers the one passed into the factory. No extra wiring, no manual passing.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ConversationId&lt;/code&gt; isn't being threaded through a call chain. It's a graph-level binding. Any class in &lt;code&gt;ChatScope&lt;/code&gt; can list it as a constructor parameter and get the right one automatically. The &lt;code&gt;ChatGraph&lt;/code&gt; instance is this specific conversation. Add a read-receipt tracker or a typing service later and they get the ID for free, for as long as the socket is alive.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@ContributesTo(AppScope::class)&lt;/code&gt; on the factory tells Metro to aggregate it into the root graph automatically, so the root graph exposes &lt;code&gt;chatGraphFactory&lt;/code&gt; without needing to declare it explicitly.&lt;/p&gt;
&lt;h4 id=&quot;the-entry-composable&quot;&gt;The entry composable&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/feature/chat/presentation/ChatEntry.kt
@Composable
fun ChatEntry(
    factory: ChatGraph.Factory,
    conversationId: ConversationId,
    onBackClick: () -&amp;gt; Unit
) {
    val graph = remember { factory.create(conversationId) }

    DisposableEffect(Unit) {
        onDispose { graph.chatSocket.close() }
    }

    CompositionLocalProvider(LocalMetroViewModelFactory provides graph.metroViewModelFactory) {
        ChatScreen(onBackClick = onBackClick)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;remember { factory.create(conversationId) }&lt;/code&gt; creates the graph once for the lifetime of this composable. The &lt;code&gt;conversationId&lt;/code&gt; comes from the navigation back-stack entry and is fixed for the session, which is why &lt;code&gt;remember&lt;/code&gt; with no key is correct here. &lt;code&gt;DisposableEffect&lt;/code&gt; with &lt;code&gt;onDispose&lt;/code&gt; calls &lt;code&gt;close()&lt;/code&gt; when the composable leaves the composition. &lt;code&gt;CompositionLocalProvider&lt;/code&gt; swaps the ViewModel factory to the one backed by &lt;code&gt;ChatScope&lt;/code&gt;, so &lt;code&gt;metroViewModel()&lt;/code&gt; calls inside &lt;code&gt;ChatScreen&lt;/code&gt; resolve from &lt;code&gt;ChatGraph&lt;/code&gt;, not the root &lt;code&gt;AppScope&lt;/code&gt; factory.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/drop-the-clutch-chat.gif&quot; alt=&quot;Chat screen: typing indicator followed by incoming message&quot;&gt;&lt;/p&gt;
&lt;p&gt;The socket starts emitting as soon as &lt;code&gt;ChatEntry&lt;/code&gt; enters the composition. While the screen is open, a new message arrives every three seconds, preceded by a one-second typing indicator. The moment you navigate away, &lt;code&gt;onDispose&lt;/code&gt; fires, &lt;code&gt;close()&lt;/code&gt; cancels the coroutine, and it stops. Navigate back to a different conversation and you get a fresh &lt;code&gt;ChatGraph&lt;/code&gt;, a fresh &lt;code&gt;ChatSocket&lt;/code&gt;, and a new &lt;code&gt;ConversationId&lt;/code&gt;, all from a single &lt;code&gt;factory.create(newId)&lt;/code&gt; call.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AppScope&lt;/code&gt; would mean the socket survives every screen transition for the app's entire lifetime. &lt;code&gt;ChatScope&lt;/code&gt; means it lives exactly as long as the chat screen. The &lt;code&gt;@Provides&lt;/code&gt; on factory parameter means runtime values flow into the scope cleanly, without leaking Android lifecycle details into the graph definition.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;gotchas-1&quot;&gt;Gotchas&lt;/h3&gt;
&lt;p&gt;Most of these I hit personally while building the demo. Metro's error messages are generally good, but a few failure modes produce errors that point at the wrong place. You see the interface in the message, not the implementation that caused the problem. That's the pattern to watch for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;internal&lt;/code&gt; on &lt;code&gt;@ContributesBinding&lt;/code&gt; classes.&lt;/strong&gt;&lt;br&gt;
If a contributed class is &lt;code&gt;internal&lt;/code&gt; (usually in a multi-module project), Metro can't aggregate it across module boundaries. The reason: Metro's compiler plugin generates glue code that references your class by name. If that class is &lt;code&gt;internal&lt;/code&gt;, the generated code sits outside the declaring module's visibility boundary and can't see it. The build succeeds, but you'll get a &lt;code&gt;MissingBinding&lt;/code&gt; error naming the interface, not the implementation. This one is hard to trace the first time. Keep contributed classes &lt;code&gt;public&lt;/code&gt; (the default) or at least within the same Gradle module as every class that needs them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;@DependencyGraph&lt;/code&gt; cannot extend &lt;code&gt;@DependencyGraph&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
Metro rejects this at compile time: &quot;Graph class may not directly extend graph class.&quot; The plain-interface pattern from Feature 2 is the correct solution: annotate only the platform-specific leaf implementations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;createGraph&lt;/code&gt; vs &lt;code&gt;createGraphFactory&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;createGraph&amp;lt;T&amp;gt;()&lt;/code&gt; expects &lt;code&gt;T&lt;/code&gt; to be a &lt;code&gt;@DependencyGraph&lt;/code&gt; with no required external parameters. &lt;code&gt;createGraphFactory&amp;lt;F&amp;gt;()&lt;/code&gt; expects &lt;code&gt;F&lt;/code&gt; to be a &lt;code&gt;@DependencyGraph.Factory&lt;/code&gt;. These are different annotations; mixing them produces a compiler error that points at the right place, but the fix isn't obvious until you understand the split.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;@GraphExtension&lt;/code&gt; vs &lt;code&gt;@DependencyGraph&lt;/code&gt; for child graphs.&lt;/strong&gt;&lt;br&gt;
Use &lt;code&gt;@GraphExtension&lt;/code&gt; when the child graph should inherit parent bindings, which is the right choice for a scoped feature graph like &lt;code&gt;ChatGraph&lt;/code&gt;. Use &lt;code&gt;@DependencyGraph&lt;/code&gt; only for the root platform graphs. Mixing them up produces a graph that either can't see parent bindings or can't be instantiated correctly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scope mismatch.&lt;/strong&gt;&lt;br&gt;
A class scoped to &lt;code&gt;ChatScope&lt;/code&gt; that gets injected into something in &lt;code&gt;AppScope&lt;/code&gt; produces a &lt;code&gt;MissingBinding&lt;/code&gt; error at compile time. The error names the interface, not the implementation. When you see it, check whether the implementation is scoped to a graph the requester can't reach.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Manifest permissions are a separate concern.&lt;/strong&gt;&lt;br&gt;
This is more an Android issue. Metro wires up a class that calls &lt;code&gt;requestPermissions&lt;/code&gt; correctly. But if &lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt; isn't declared in &lt;code&gt;AndroidManifest.xml&lt;/code&gt;, the dialog won't appear and there's no error anywhere. Always check the manifest first if a permission flow does nothing.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-2&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;Metro moves dependency graph validation from runtime to compile time, and that shift changes how you think about DI mistakes. The patterns here cover most of the ground: binding contributions for everyday features, the platform graph split for anything that needs Android or iOS types, and child scopes for dependencies with a real lifecycle, including runtime values like a conversation ID flowing in through a factory parameter. Get those three things in place and the rest of the wiring tends to solve itself.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The paddle shifters don't make you a faster driver. But they do free up your hands for the things that actually determine where you finish.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The full demo for this article is available on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/kmpbits/metroDIDemo&quot;&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The KMP Bits app is available on &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/pt/app/bitsreader/id6755438235&quot;&gt;App Store&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://play.google.com/store/apps/details?id=com.joel.bitsreader&quot;&gt;Google Play&lt;/a&gt;, built entirely with KMP.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19771/drop-the-clutch-three-metro-di-patterns-every-kmp-developer-should-know-part-2</guid>
<pubDate>Fri, 05 Jun 2026 19:03:27 +0000</pubDate>
</item>
<item>
<title>Drop the Clutch: Three Metro DI Patterns Every KMP Developer Should Know - Part 1</title>
<link>https://coderlegion.com/19769/drop-the-clutch-three-metro-di-patterns-every-kmp-developer-should-know-part-1</link>
<description>&lt;p&gt;In a GT3 car, you have a choice. You can drive with a traditional manual gearbox: clutch pedal, H-pattern shifter, full control over every gear change. Or you can switch to the sequential paddle shifters that most modern GT3 cars run. The car is still yours to drive. The racing line, the braking points, the tyre management. All of that stays with you. The only thing that changes is that you stop managing something that was never really the point.&lt;/p&gt;
&lt;p&gt;I've been running Koin in a multi-module Compose Multiplatform project for a while, and for a long time it felt like the manual gearbox. Not painful exactly, just present. Every new dependency meant another &lt;code&gt;module { }&lt;/code&gt; block somewhere, another &lt;code&gt;get()&lt;/code&gt; in the factory lambda, another place where the compiler had nothing to say if I forgot to wire something. The error came at runtime, and usually in a context that made it harder to trace than it should have been.&lt;/p&gt;
&lt;p&gt;Metro is the paddle shifters. You annotate your classes, define a graph interface, and the Kotlin compiler plugin generates the wiring at build time. If something is missing from the graph, the build fails. Not the app launch. The build.&lt;/p&gt;
&lt;p&gt;I already covered Koin Annotations in a &lt;a rel=&quot;nofollow&quot; href=&quot;/posts/koin-annotations&quot;&gt;previous article&lt;/a&gt;. What follows isn't a migration guide. It's a look at three specific patterns that come up in every real KMP project and where Metro's behaviour changed how I think about dependency injection. This is Part 1 — setup, the basics of Metro's model, and the binding pattern you'll use for most features. Part 2 covers platform-specific graphs and scoped lifetimes.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-metro-is&quot;&gt;What Metro is&lt;/h3&gt;
&lt;p&gt;Metro is a compile-time dependency injection framework for Kotlin Multiplatform. It ships as a Kotlin compiler plugin, not an annotation processor or a KSP plugin. Code generation happens in the compiler's FIR/IR pipeline directly.&lt;/p&gt;
&lt;p&gt;The mental model draws from three places: &lt;code&gt;@Inject&lt;/code&gt;, &lt;code&gt;@Provides&lt;/code&gt;, and the scope system come from Dagger; the interface-based graph definition comes from kotlin-inject; and the contribution aggregation system comes from Anvil. If you've worked with any of those three, the ideas transfer quickly.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;metro-koin-annotations-and-dagger&quot;&gt;Metro, Koin Annotations, and Dagger&lt;/h3&gt;
&lt;p&gt;Before getting into the patterns, it's worth placing Metro relative to the tools you've probably already used.&lt;/p&gt;
&lt;h4 id=&quot;koin-annotations&quot;&gt;Koin Annotations&lt;/h4&gt;
&lt;p&gt;The comparison people reach for first is Koin Annotations, because both claim compile-time safety. I want to be clear about something: these are not competing answers to the same question. They solve the same problem, both do it well, and the choice comes down to what your project actually needs.&lt;/p&gt;
&lt;p&gt;Koin Annotations uses KSP to generate source files during the build. It catches graph errors in the KSP phase, which is earlier than runtime, but the Koin service locator container still exists underneath. If something slips through, the app can still throw on startup. The generated &lt;code&gt;.kt&lt;/code&gt; files land in &lt;code&gt;build/generated/ksp/&lt;/code&gt;, and you need KSP configured and on the right classpath. For most projects, that's no trouble at all. Koin is mature, widely used, and well documented.&lt;/p&gt;
&lt;p&gt;Metro works at a different level. It's a compiler plugin operating on the IR representation of your code, the same layer where kotlinx.serialization and the Compose compiler plugin live. There are no generated source files, no &lt;code&gt;build/generated/&lt;/code&gt; directory, no KSP classpath to manage. The output is direct constructor calls baked into bytecode. Metro has no runtime container. If the graph is invalid, the build doesn't produce a binary.&lt;/p&gt;
&lt;p&gt;The trade-offs are real on both sides. Metro is strictly static, so dynamic or conditional bindings that Koin handles without friction are outside Metro's scope. And Metro is a newer library, primarily maintained by one person. Koin has years of production use behind it.&lt;/p&gt;
&lt;p&gt;My take: if your team already knows Koin, or you need dynamic binding flexibility, stick with Koin Annotations. If you want the strictest compile-time guarantee with no runtime container underneath, Metro is the right call.&lt;/p&gt;
&lt;h4 id=&quot;if-you-know-dagger&quot;&gt;If you know Dagger&lt;/h4&gt;
&lt;p&gt;If you've used Dagger before, Metro will feel familiar in the right places. &lt;code&gt;@Inject&lt;/code&gt;, &lt;code&gt;@Provides&lt;/code&gt;, scope annotations, and compile-time graph validation are all there. The graph interface replaces Dagger's &lt;code&gt;@Component&lt;/code&gt;, &lt;code&gt;@DependencyGraph.Factory&lt;/code&gt; replaces &lt;code&gt;@Component.Builder&lt;/code&gt;, and &lt;code&gt;@ContributesBinding&lt;/code&gt; replaces the manual &lt;code&gt;@Binds&lt;/code&gt; plus module wiring you'd write by hand in Dagger.&lt;/p&gt;
&lt;p&gt;Metro is a compiler plugin rather than an annotation processor, so no KAPT, no KSP, no generated source files to track. The interface-based graph definition is cleaner than Dagger's abstract class components. And Metro supports Kotlin Multiplatform natively. Dagger never did. If you've used Hilt, the &lt;code&gt;@ContributesBinding&lt;/code&gt; aggregation pattern will look familiar: Metro borrows it from Anvil, which is what Hilt builds on.&lt;/p&gt;
&lt;p&gt;Where Dagger still has an edge: it's been used in large Android codebases for over a decade, the tooling is more mature, and the error messages are better when something goes wrong. If you're on a large Android-only project with an existing Dagger graph, there's rarely a reason to migrate.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-demo&quot;&gt;The demo&lt;/h3&gt;
&lt;p&gt;The three patterns I want to show are easier to understand in context than in isolation. To make them concrete, I built a fake real-time chat app: a conversations list, a notification permission screen, and a chat room with a scoped WebSocket. No server, no real backend. Just hardcoded data and coroutine timers. Each screen demonstrates one Metro concept, and together they cover the situations you'll hit in almost any real KMP project.&lt;/p&gt;
&lt;p&gt;The article focuses entirely on the DI wiring. The UI is minimal by design.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;setup&quot;&gt;Setup&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;# gradle/libs.versions.toml
[versions]
metro = &quot;1.0.0&quot;

[plugins]
metro = { id = &quot;dev.zacsweers.metro&quot;, version.ref = &quot;metro&quot; }

[libraries]
metro-viewmodel = { module = &quot;dev.zacsweers.metro:metrox-viewmodel&quot;, version.ref = &quot;metro&quot; }
metro-viewmodel-compose = { module = &quot;dev.zacsweers.metro:metrox-viewmodel-compose&quot;, version.ref = &quot;metro&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// build.gradle.kts (root)
plugins {
    alias(libs.plugins.metro) apply false
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apply the plugin to every module that contains Metro annotations and add the ViewModel extensions to the modules that need them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// composeApp/build.gradle.kts
plugins {
    alias(libs.plugins.metro)
}

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.metro.viewmodel)
            implementation(libs.metro.viewmodel.compose)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;metrox-viewmodel&lt;/code&gt; and &lt;code&gt;metrox-viewmodel-compose&lt;/code&gt; are what give you &lt;code&gt;ViewModelGraph&lt;/code&gt;, &lt;code&gt;LocalMetroViewModelFactory&lt;/code&gt;, and &lt;code&gt;@ContributesIntoMap&lt;/code&gt; for lifecycle-aware ViewModel injection. The demo uses all three.&lt;/p&gt;
&lt;p&gt;One Gradle plugin, two extra libraries. No annotation processor configuration, no generated source directories to wire into your source sets.&lt;/p&gt;
&lt;p&gt;The project organises code by package rather than separate Gradle modules. Metro's contribution system works at the annotation level, not the module boundary, so packages are enough. A scope marker is all you need to get started:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/di/AppScope.kt
import dev.zacsweers.metro.Scope

@Scope
annotation class AppScope
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;feature-1-conversations-list&quot;&gt;Feature 1: Conversations list&lt;/h3&gt;
&lt;p&gt;This is the baseline. The pattern you'll use for most features in any KMP project.&lt;/p&gt;
&lt;p&gt;The interface lives in the domain layer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/feature/conversations/domain/ConversationRepository.kt
interface ConversationRepository {
    suspend fun getConversations(): List&amp;lt;Conversation&amp;gt;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The implementation attaches itself to the graph:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/feature/conversations/data/FakeConversationRepository.kt
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import kotlinx.coroutines.delay

@Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class FakeConversationRepository : ConversationRepository {

    override suspend fun getConversations(): List&amp;lt;Conversation&amp;gt; {
        delay(300) // fake network latency
        return listOf(
            Conversation(id = &quot;1&quot;, name = &quot;Garage channel&quot;, lastMessage = &quot;Tyres are warm.&quot;),
            Conversation(id = &quot;2&quot;, name = &quot;Pit wall&quot;, lastMessage = &quot;Box this lap.&quot;)
        )
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@ContributesBinding(AppScope::class)&lt;/code&gt; tells Metro: this class implements &lt;code&gt;ConversationRepository&lt;/code&gt; and belongs to &lt;code&gt;AppScope&lt;/code&gt;. The root graph never needs to declare it. Metro aggregates everything contributed to a scope automatically at compile time. &lt;code&gt;@SingleIn(AppScope::class)&lt;/code&gt; keeps one instance alive for the app's lifetime. Remove it and Metro creates a new repository on every injection, which is almost never what you want for a repository.&lt;/p&gt;
&lt;p&gt;This is the pattern that makes multi-feature wiring low-ceremony. Each feature declares its own contributions and the root graph stays small.&lt;/p&gt;
&lt;h4 id=&quot;lifecycle-aware-viewmodel-injection&quot;&gt;Lifecycle-aware ViewModel injection&lt;/h4&gt;
&lt;p&gt;The ViewModel follows the same constructor injection pattern, but contributing it uses a map:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/feature/conversations/presentation/ConversationsViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.ViewModelKey
import dev.zacsweers.metro.binding
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

@Inject
@ContributesIntoMap(AppScope::class, binding = binding&amp;lt;ViewModel&amp;gt;())
@ViewModelKey(ConversationsViewModel::class)
class ConversationsViewModel(
    private val repository: ConversationRepository
) : ViewModel() {

    private val _conversations = MutableStateFlow&amp;lt;List&amp;lt;Conversation&amp;gt;&amp;gt;(emptyList())
    val conversations: StateFlow&amp;lt;List&amp;lt;Conversation&amp;gt;&amp;gt; = _conversations

    init {
        viewModelScope.launch {
            _conversations.value = repository.getConversations()
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@ContributesIntoMap&lt;/code&gt; puts this ViewModel into a map that Metro generates for the scope. The &lt;code&gt;@ViewModelKey&lt;/code&gt; annotation sets the key. That map feeds into &lt;code&gt;MetroViewModelFactory&lt;/code&gt;, an abstract class from Metro's ViewModel extensions that you subclass once per scope.&lt;/p&gt;
&lt;p&gt;Metro won't provide &lt;code&gt;MetroViewModelFactory&lt;/code&gt; automatically. It's abstract and has no &lt;code&gt;@Inject&lt;/code&gt; constructor. You bridge the gap with a small file that subclasses it for each scope:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/di/ViewModelFactory.kt
@Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class AppViewModelFactory(
    override val viewModelProviders: Map&amp;lt;KClass&amp;lt;out ViewModel&amp;gt;, () -&amp;gt; ViewModel&amp;gt;,
    override val assistedFactoryProviders: Map&amp;lt;KClass&amp;lt;out ViewModel&amp;gt;, () -&amp;gt; ViewModelAssistedFactory&amp;gt;,
    override val manualAssistedFactoryProviders: Map&amp;lt;KClass&amp;lt;out ManualViewModelAssistedFactory&amp;gt;, () -&amp;gt; ManualViewModelAssistedFactory&amp;gt;,
) : MetroViewModelFactory()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The class is &lt;code&gt;@Inject&lt;/code&gt;, so Metro can construct it. The three maps are populated from &lt;code&gt;@ContributesIntoMap&lt;/code&gt; contributions in &lt;code&gt;AppScope&lt;/code&gt;. &lt;code&gt;@ContributesBinding&lt;/code&gt; wires &lt;code&gt;AppViewModelFactory&lt;/code&gt; to the &lt;code&gt;MetroViewModelFactory&lt;/code&gt; binding in the scope. (You'll see the &lt;code&gt;ChatViewModelFactory&lt;/code&gt; equivalent in Part 2, when we introduce a second scope.)&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ViewModelGraph&lt;/code&gt; then exposes &lt;code&gt;metroViewModelFactory&lt;/code&gt;, which you provide through a &lt;code&gt;CompositionLocal&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain/App.kt
@Composable
fun App(graph: RootGraph) {
    CompositionLocalProvider(LocalMetroViewModelFactory provides graph.metroViewModelFactory) {
        AppTheme { AppEntry() }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inside any screen below that provider, &lt;code&gt;metroViewModel&amp;lt;ConversationsViewModel&amp;gt;()&lt;/code&gt; resolves from the map, scoped to the back stack entry, cleaned up when the screen pops. The Metro wiring doesn't touch any of that: it just provides the factory.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;A quick note on &lt;code&gt;metroViewModelFactory&lt;/code&gt;:&lt;/strong&gt; Compose's &lt;code&gt;viewModel()&lt;/code&gt; function uses reflection by default, which doesn't work with Metro since there's no runtime container. &lt;code&gt;metroViewModel&amp;lt;T&amp;gt;()&lt;/code&gt; from &lt;code&gt;metrox-viewmodel-compose&lt;/code&gt; reads &lt;code&gt;LocalMetroViewModelFactory&lt;/code&gt; and constructs ViewModels from the contributed map instead. The &lt;code&gt;MetroViewModelFactory&lt;/code&gt; subclass you write in &lt;code&gt;ViewModelFactory.kt&lt;/code&gt; is the bridge: it takes the generated provider maps as constructor arguments and wires them into the factory. Everything else works exactly as you'd expect from the Jetpack ViewModel API.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The thing that clicked for me: &lt;code&gt;@ContributesBinding&lt;/code&gt; contributes a single binding, &lt;code&gt;@ContributesIntoMap&lt;/code&gt; contributes an entry to a map. Both are aggregated automatically. Neither requires touching the root graph.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-s-next&quot;&gt;What's next&lt;/h3&gt;
&lt;p&gt;Part 1 covers the foundation: how Metro differs from Koin and Dagger, how to wire up a simple feature with &lt;code&gt;@ContributesBinding&lt;/code&gt;, and how ViewModel injection works through contributed maps.&lt;/p&gt;
&lt;p&gt;[Part 2]([PART 2 LINK]) is where it gets more interesting. It covers two patterns that every multiplatform project eventually runs into: how to split platform-specific graph implementations correctly (spoiler: the common interface carries no Metro annotations at all), and how to create a scoped graph for dependencies with a real lifecycle — including how a runtime value like a conversation ID flows into a child scope through a factory parameter.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The full demo for this article is available on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/kmpbits/metroDIDemo&quot;&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The KMP Bits app is available on &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/pt/app/bitsreader/id6755438235&quot;&gt;App Store&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://play.google.com/store/apps/details?id=com.joel.bitsreader&quot;&gt;Google Play&lt;/a&gt;, built entirely with KMP.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19769/drop-the-clutch-three-metro-di-patterns-every-kmp-developer-should-know-part-1</guid>
<pubDate>Fri, 05 Jun 2026 19:03:01 +0000</pubDate>
</item>
<item>
<title>Kotlin Professional Certificate by JetBrains</title>
<link>https://coderlegion.com/18439/kotlin-professional-certificate-by-jetbrains</link>
<description>&lt;p&gt;JetBrains has launched the Kotlin Professional Certificate by JetBrains, a new LinkedIn Learning path designed to help developers build practical Kotlin and Kotlin Multiplatform skills.&lt;/p&gt;
&lt;p&gt;The program is structured around four courses and takes developers from Kotlin fundamentals through object-oriented and asynchronous programming, Kotlin Multiplatform development, and full-stack app development with Ktor and Compose Multiplatform. JetBrains says the certificate is aimed at developers with basic programming knowledge who want to learn Kotlin and explore how it can be used across Android, iOS, web, desktop, and backend environments.&lt;/p&gt;
&lt;p&gt;The course includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Kotlin Essential Training&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Kotlin Multiplatform&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Ktor&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Compose&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;After completing the course and passing the final exam, developers can earn a certificate that can be downloaded, shared, and added to a LinkedIn profile.&lt;/p&gt;
&lt;p&gt;Additional info at:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.jetbrains.com/kotlin/2026/04/kotlin-professional-certificate-by-jetbrains-now-on-linkedin-learning/&quot;&gt;https://blog.jetbrains.com/kotlin/2026/04/kotlin-professional-certificate-by-jetbrains-now-on-linkedin-learning/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Link to the course:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/learning/paths/kotlin-professional-certificate-by-jetbrains&quot;&gt;https://www.linkedin.com/learning/paths/kotlin-professional-certificate-by-jetbrains&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/18439/kotlin-professional-certificate-by-jetbrains</guid>
<pubDate>Wed, 20 May 2026 10:00:04 +0000</pubDate>
</item>
<item>
<title>JetBrains Just Changed KMP Structure. Here's What They Didn't Tell You.</title>
<link>https://coderlegion.com/18238/jetbrains-just-changed-kmp-structure-heres-what-they-didnt-tell-you</link>
<description>&lt;p&gt;JetBrains &lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.jetbrains.com/kotlin/2026/05/new-kmp-default-structure/&quot;&gt;announced&lt;/a&gt; a new default project structure for KMP. Two shared modules — &lt;code&gt;sharedLogic&lt;/code&gt; and &lt;code&gt;sharedUI&lt;/code&gt; — plus separate application modules for each platform. Clean. Simple. Obvious.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=17995218555422079821&quot; alt=&quot;New KMP template&quot;&gt;&lt;/p&gt;
&lt;p&gt;It's also a trap.&lt;/p&gt;
&lt;h3 id=&quot;the-problem-with-sharedlogic-and-sharedui&quot;&gt;The problem with &lt;code&gt;sharedLogic&lt;/code&gt; and &lt;code&gt;sharedUI&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;A fresh &lt;code&gt;sharedLogic&lt;/code&gt; module looks innocent. Business logic, models, repositories. &lt;code&gt;sharedUI&lt;/code&gt; holds your Composables. What could go wrong?&lt;/p&gt;
&lt;p&gt;Three months later &lt;code&gt;sharedLogic&lt;/code&gt; contains:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Business logic&lt;/li&gt;
&lt;li&gt;Network clients&lt;/li&gt;
&lt;li&gt;Database models&lt;/li&gt;
&lt;li&gt;Constants&lt;/li&gt;
&lt;li&gt;Utility functions&lt;/li&gt;
&lt;li&gt;Something called &lt;code&gt;CommonUtils.kt&lt;/code&gt; that nobody understands but everyone is afraid to delete&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;sharedLogic&lt;/code&gt; has no internal boundaries. Everything depends on everything. You can import a database model from a use case and nobody stops you. The compiler certainly won't.&lt;/p&gt;
&lt;p&gt;JetBrains tells you where to start. They don't tell you what happens when two modules become 50,000 lines.&lt;/p&gt;
&lt;h3 id=&quot;what-actually-happens&quot;&gt;What actually happens&lt;/h3&gt;
&lt;p&gt;You try to split &lt;code&gt;sharedLogic&lt;/code&gt; into more modules. But there are no rules. So you end up with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;:sharedLogic:core&lt;/code&gt; — the new dumping ground&lt;/li&gt;
&lt;li&gt;&lt;code&gt;:sharedLogic:data&lt;/code&gt; — which also contains business logic somehow&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The dependencies look like spaghetti. Changing one module breaks three others. You didn't enforce boundaries early, and now every refactoring is a gamble.&lt;/p&gt;
&lt;h3 id=&quot;flip-the-next-step-after-sharedlogic&quot;&gt;FLIP: the next step after &lt;code&gt;sharedLogic&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/numq/flip&quot;&gt;FLIP&lt;/a&gt; is an architectural pattern that gives you rules for splitting &lt;code&gt;sharedLogic&lt;/code&gt; before it becomes a problem.&lt;/p&gt;
&lt;p&gt;Five layers. One direction. Zero horizontal dependencies.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Layer &lt;/th&gt;&lt;th&gt;Purpose &lt;/th&gt;&lt;th&gt;Depends on&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;:common:core &lt;/td&gt;&lt;td&gt;Foundation &lt;/td&gt;&lt;td&gt;Nothing&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;:service &lt;/td&gt;&lt;td&gt;Domain logic without UI &lt;/td&gt;&lt;td&gt;&lt;code&gt;:common:core&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;:feature &lt;/td&gt;&lt;td&gt;UI features &lt;/td&gt;&lt;td&gt;&lt;code&gt;:service&lt;/code&gt;, &lt;code&gt;:common:core&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;:entrypoint &lt;/td&gt;&lt;td&gt;DI assembly + slot composition &lt;/td&gt;&lt;td&gt;Everything above&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;:platform &lt;/td&gt;&lt;td&gt;Platform entry point &lt;/td&gt;&lt;td&gt;&lt;code&gt;:entrypoint&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The rule is simple: &lt;strong&gt;features don't know about other features. services don't know about other services.&lt;/strong&gt; Dependencies only flow down.&lt;/p&gt;
&lt;p&gt;Your IDE enforces this. You literally cannot import a feature from another feature — Gradle won't compile. The structure prevents the dumpster fire before it starts.&lt;/p&gt;
&lt;h3 id=&quot;start-with-jetbrains-grow-with-flip-&quot;&gt;Start with JetBrains. Grow with FLIP.&lt;/h3&gt;
&lt;p&gt;The new KMP structure is a great starting point. Two modules is better than one. But when &lt;code&gt;sharedLogic&lt;/code&gt; starts feeling like a junk drawer, you know what to do.&lt;/p&gt;
&lt;p&gt;FLIP is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/numq/flip&quot;&gt;github.com/numq/flip&lt;/a&gt;. Template included. Konsist tests included. Rules over conventions.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/18238/jetbrains-just-changed-kmp-structure-heres-what-they-didnt-tell-you</guid>
<pubDate>Mon, 18 May 2026 18:26:05 +0000</pubDate>
</item>
<item>
<title>FLIP: Modular Architecture for KMP</title>
<link>https://coderlegion.com/18126/flip-modular-architecture-for-kmp</link>
<description>&lt;p&gt;While building &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.github.com/numq/haskcore&quot;&gt;haskcore&lt;/a&gt;&lt;/strong&gt; - an IDE for Haskell on Compose Desktop - I extracted rules that allowed me to create an architecture of 40 isolated modules, where each module knows only what it needs.&lt;/p&gt;
&lt;p&gt;That's how &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.github.com/numq/flip&quot;&gt;FLIP - Feature-Layered Isolated Platform&lt;/a&gt;&lt;/strong&gt; - was born.&lt;/p&gt;
&lt;p&gt;The main problem I faced during development: &lt;strong&gt;horizontal dependencies make the system fragile&lt;/strong&gt;. The solution - strict vertical hierarchy.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=1320007955686771447&quot; alt=&quot;FLIP layers&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FLIP&lt;/strong&gt; consists of the following isolated modules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;:common:core&lt;/code&gt; - common logic components.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;:common:presentation&lt;/code&gt; - common UI components.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;:service&lt;/code&gt; - UI-less domain module. Provides a service interface.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;interface SeedService : AutoCloseable {
    val seed: Flow&amp;lt;Seed&amp;gt;
    suspend fun generateSeed(): Either&amp;lt;Throwable, Unit&amp;gt;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;:feature:core&lt;/code&gt; — logic module of a UI-feature. Knows nothing about other features. Uses services from &lt;code&gt;:service&lt;/code&gt;. Provides use cases.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;class GenerateRemoteRandomSeed(private val seedService: SeedService) : UseCase.Action {
    override suspend fun Raise&amp;lt;Throwable&amp;gt;.action() = seedService.generateSeed().bind()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;:feature:presentation&lt;/code&gt; — UI module of a UI-feature. Knows only about its &lt;code&gt;:feature:core&lt;/code&gt; and uses its use cases. Provides the view. Knows only about its own &lt;code&gt;:feature:core&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;:entrypoint&lt;/code&gt; — module where dependency configuration and composition of views from &lt;code&gt;:feature:presentation&lt;/code&gt; into a single Composable happens.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;:platform&lt;/code&gt; — module that initializes the Composable provided by &lt;code&gt;:entrypoint&lt;/code&gt; for each platform.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=9437479124804445323&quot; alt=&quot;FLIP dependencies&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Arrows show the direction of dependencies.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;how-to-structure-your-system-&quot;&gt;How to structure your system?&lt;/h3&gt;
&lt;p&gt;Decide which features your app should have, then determine whether each feature has a UI:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If it does — it’s a &lt;strong&gt;feature&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;If it doesn’t — it’s a &lt;strong&gt;service&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;If one feature’s logic is needed by another — extract it into a separate &lt;strong&gt;service&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Important: both &lt;code&gt;:service&lt;/code&gt; and &lt;code&gt;:feature:core&lt;/code&gt; contain domain logic:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;:service&lt;/code&gt; — core domain.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;:feature:core&lt;/code&gt; — presentation domain (use cases).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Wrong:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;:service:foo&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;:feature:foo:core&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;:feature:foo:presentation&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Right:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;:service:foo&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;:feature:bar:core&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;:feature:bar:presentation&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Navigation in &lt;strong&gt;FLIP&lt;/strong&gt; is just another feature. It aggregates and routes views passed as lambda slots, keeping it independent from other features.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;NavigationView(
    splash = {
        SplashView(applicationScope = applicationScope)
    },
    profile = {
        ProfileView(applicationScope = applicationScope)
    }
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This approach lets you add new screens without touching existing features.&lt;/p&gt;
&lt;p&gt;A complete example is available in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.github.com/numq/flip&quot;&gt;official GitHub repository&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/18126/flip-modular-architecture-for-kmp</guid>
<pubDate>Sun, 17 May 2026 04:49:14 +0000</pubDate>
</item>
<item>
<title>Essential Claude Code Skills for Mobile Developers</title>
<link>https://coderlegion.com/13848/essential-claude-code-skills-for-mobile-developers</link>
<description>&lt;p&gt;Claude Code skills are packaged folders of instructions, scripts and resources that teach the AI coding agent how to perform specialised tasks.&lt;/p&gt;
&lt;p&gt;Each skill has a &lt;code&gt;SKILL.md&lt;/code&gt; file containing YAML metadata and step by step instructions. Claude scans the names and descriptions of installed skills and loads the full content only when your request matches.&lt;/p&gt;
&lt;p&gt;Because skills are part of an open standard adopted by multiple agent frameworks, a single skill can work across Claude Code, OpenAI Codex, Cursor and other tools.&lt;/p&gt;
&lt;h3 id=&quot;installing-claude-code-skills&quot;&gt;Installing Claude Code skills&lt;/h3&gt;
&lt;p&gt;Skills can be installed globally or per project. Personal skills go into &lt;code&gt;~/.claude/skills/&lt;/code&gt; and become available across all your projects, while project specific skills live in &lt;code&gt;.claude/skills/&lt;/code&gt; inside your repository so everyone who clones the repo can use them.&lt;/p&gt;
&lt;p&gt;Most skills are plain Markdown folders: clone the repository and copy the skill directory into the appropriate folder. For example, the Swift Development skill’s README shows that you can install it by copying the Swift Development skill directory into &lt;code&gt;~/.claude/skills/&lt;/code&gt; and then invoking it via &lt;code&gt;/swift-development&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;recommended-skills-for-mobile-developers&quot;&gt;Recommended skills for mobile developers&lt;/h3&gt;
&lt;p&gt;Below is a curated list of Claude Code skills that are particularly valuable for mobile developers working with native iOS, Android or Kotlin Multiplatform (KMP). The list covers a range of design and development tasks. Each skill is linked to its repository or marketplace page along with a brief description.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Swift Development&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/hmohamed01/swift-development&quot;&gt;https://github.com/hmohamed01/swift-development&lt;/a&gt;&lt;br&gt;
This comprehensive skill turns Claude into a competent Swift engineer. It packages patterns for SwiftUI navigation, concurrency (async/await, actors) and testing frameworks, along with helper scripts for building, running tests, formatting code and managing simulators. Installation is simple: copy the Swift Development directory into your personal skills folder and invoke it with &lt;code&gt;/swift-development&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;iOS Mobile Design&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://mcpmarket.com/tools/skills/ios-mobile-design&quot;&gt;https://mcpmarket.com/tools/skills/ios-mobile-design&lt;/a&gt;&lt;br&gt;
For designers and developers building polished iOS apps, this skill provides expert guidance on Apple’s Human Interface Guidelines. It teaches Claude how to apply SwiftUI layout systems, dynamic type, SF Symbols and modern navigation patterns so interfaces feel native to iPhone, iPad and visionOS. Key features include accessible design with dynamic type, responsive layouts, Material You style theming and Dark Mode optimisation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;iOS App Builder&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://mcpmarket.com/tools/skills/ios-app-builder&quot;&gt;https://mcpmarket.com/tools/skills/ios-app-builder&lt;/a&gt;&lt;br&gt;
This skill transforms Claude into a senior iOS developer who builds and ships native apps using a command line only workflow. It uses Xcode’s xcodebuild and simctl tools to scaffold projects, run tests and launch apps without opening Xcode. The skill emphasises a “prove, don’t promise” methodology: it verifies logic through tests and simulator screenshots, manages the full app life cycle from scaffolding to App Store readiness and includes features like performance profiling and memory leak detection&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SwiftUI agent skill&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/twostraws/swiftui-agent-skill&quot;&gt;https://github.com/twostraws/swiftui-agent-skill&lt;/a&gt;&lt;br&gt;
A SwiftUI skill create by Paul Hudson (hacking with swift).&lt;br&gt;
This skill helps identify and fix common mistakes AI agents make when writing SwiftUI – things like modern API usage, performance, accessibility, and more.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Android Development&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dpconde/claude-android-skill&quot;&gt;https://github.com/dpconde/claude-android-skill&lt;/a&gt;&lt;br&gt;
Designed for modern Android projects, this skill teaches Claude clean architecture with separate UI, domain and data layers. It covers Jetpack Compose patterns and best practices, multi module project structures, offline first architecture using Room and reactive streams, dependency injection with Hilt and comprehensive testing strategies. After cloning the repository into your skills folder, you can ask the agent to scaffold features, create Compose screens or set up repositories following Android best practices.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Android Kotlin Development&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://fastmcp.me/skills/details/241/android-kotlin-development&quot;&gt;https://fastmcp.me/skills/details/241/android-kotlin-development&lt;/a&gt;&lt;br&gt;
This skill focuses on building native Android apps with Kotlin. The description explains that it covers Model-View-ViewModel architecture with Jetpack, Compose for declarative user interfaces, Retrofit for API calls, Room for local storage and navigation patterns. It includes detailed instructions and code samples for models, API services and MVVM view models, making it a practical guide for Kotlin developers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mobile Android Design&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://fastmcp.me/skills/details/644/mobile-android-design&quot;&gt;https://fastmcp.me/skills/details/644/mobile-android-design&lt;/a&gt;&lt;br&gt;
When you need to design Android interfaces that follow Google’s Material Design 3 guidelines, this skill is indispensable. It teaches Claude how to build adaptive layouts with Jetpack Compose, implement navigation patterns like bottom navigation and drawers, use dynamic colour and Material You theming, and create accessible UI for phones, tablets and foldables. It is especially helpful for modernising existing apps or ensuring new designs comply with Material Design principles.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Jetpack Compose Expert&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aldefy/compose-skill&quot;&gt;https://github.com/aldefy/compose-skill&lt;/a&gt;&lt;br&gt;
This skill provides Claude with real source code and extensive reference guides for Jetpack Compose. Without it, AI tools often misuse remember, generate inefficient recompositions or misorder modifiers; with the skill, Claude picks the right state primitive, applies stability annotations and orders modifiers correctly. The reference guides cover state management, view composition, performance, navigation, animation, lists, side effects, modifiers, theming, accessibility and deprecated patterns. Installation requires cloning the repository and copying the Jetpack Compose expert skill directory into your skills folder; Claude will automatically activate it when you mention Compose or related API names.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Kotlin Multiplatform (KMP) Abstraction Guide&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://fastmcp.me/skills/details/2190/kotlin-multiplatform&quot;&gt;https://fastmcp.me/skills/details/2190/kotlin-multiplatform&lt;/a&gt;&lt;br&gt;
KMP projects often involve tough decisions about what to share between platforms and what to keep platform specific. This skill guides those decisions by providing a platform abstraction decision tree. It explains when to use &lt;code&gt;commonMain&lt;/code&gt;, &lt;code&gt;jvmAndroid&lt;/code&gt; or &lt;code&gt;expect/actual&lt;/code&gt; patterns, covers targets like Android, JVM/Desktop and iOS, and offers triggers for questions such as &quot;should I share this ViewModel?&quot;. It helps developers place code correctly and prepare for future web or WebAssembly targets.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13848/essential-claude-code-skills-for-mobile-developers</guid>
<pubDate>Thu, 02 Apr 2026 07:22:03 +0000</pubDate>
</item>
<item>
<title>Essential RSS Feeds for Mobile Developers</title>
<link>https://coderlegion.com/9324/essential-rss-feeds-for-mobile-developers</link>
<description>&lt;p&gt;Keeping up with mobile development requires a steady flow of high quality information. RSS feeds are still one of the best ways to follow platform updates, deep technical articles, and long form insights without relying on social media algorithms.&lt;/p&gt;
&lt;h3 id=&quot;what-are-rss-feeds&quot;&gt;What are RSS feeds&lt;/h3&gt;
&lt;p&gt;RSS stands for Really Simple Syndication.&lt;br&gt;
An RSS feed is a constantly updated stream of content published by a website. Instead of visiting dozens of sites every day, you subscribe to their feeds and read all new articles in one place.&lt;/p&gt;
&lt;p&gt;When a site publishes a new post, it automatically appears in your RSS reader. No algorithms, no distractions, no missing important updates.&lt;/p&gt;
&lt;p&gt;RSS is especially useful for developers because it lets you follow official blogs, deep technical articles, and long form content in a clean chronological order.&lt;/p&gt;
&lt;h3 id=&quot;how-to-read-rss-feeds&quot;&gt;How to read RSS feeds&lt;/h3&gt;
&lt;p&gt;To read RSS feeds you need an RSS reader. An RSS reader is an app or web service where you add feed links and read new posts as they appear.&lt;/p&gt;
&lt;p&gt;You copy the feed URL and add it to the reader. From that moment on, new articles show up automatically.&lt;/p&gt;
&lt;h3 id=&quot;popular-free-rss-readers&quot;&gt;Popular free RSS readers&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Feedly&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://feedly.com/&quot;&gt;https://feedly.com/&lt;/a&gt;&lt;br&gt;
One of the most popular RSS readers. Available on web, iOS, and Android. Easy to use and great for organizing feeds into folders. The free plan is more than enough for most developers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Inoreader&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.inoreader.com&quot;&gt;https://www.inoreader.com&lt;/a&gt;&lt;br&gt;
A powerful RSS reader with excellent filtering and organization features. Works on web and mobile. Very popular among power users and developers who follow many feeds.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;NetNewsWire&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://netnewswire.com/&quot;&gt;https://netnewswire.com/&lt;/a&gt;&lt;br&gt;
A free and open source RSS reader for Apple platforms. Very fast, clean, and privacy focused. Ideal if you want a native macOS and iOS experience without cloud services.&lt;/p&gt;
&lt;h3 id=&quot;a-curated-list-of-essential-rss-feeds-for-mobile-developers&quot;&gt;A curated list of essential RSS feeds for mobile developers&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Android Developers Blog&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://android-developers.googleblog.com/atom.xml&quot;&gt;https://android-developers.googleblog.com/atom.xml&lt;/a&gt;&lt;br&gt;
The official Android Developers Blog published by Google. It covers platform changes, new APIs, tooling updates, Jetpack libraries, performance improvements, and long term Android roadmap insights.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Google Android Medium Publication&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/feed/androiddevelopers&quot;&gt;https://medium.com/feed/androiddevelopers&lt;/a&gt;&lt;br&gt;
Articles written by Android engineers at Google and the community. Often complements the official Android Developers Blog with practical examples and deep dives.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Android Weekly&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://androidweekly.net/rss.xml&quot;&gt;https://androidweekly.net/rss.xml&lt;/a&gt;&lt;br&gt;
A weekly curated newsletter with links to the best Android articles, libraries, tools, and open source projects. Very useful for discovering high quality content you might otherwise miss.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AppCoda&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.appcoda.com/rss/&quot;&gt;https://www.appcoda.com/rss/&lt;/a&gt;&lt;br&gt;
A popular blog focused mainly on iOS development. It provides practical tutorials on Swift, SwiftUI, UIKit, and Apple platform APIs with clear explanations and code examples.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Apple Developer News&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;http://developer.apple.com/news/rss/news.rss&quot;&gt;http://developer.apple.com/news/rss/news.rss&lt;/a&gt;&lt;br&gt;
The official Apple developer feed. It includes announcements about SDK changes, App Store policies, WWDC updates, platform releases, and new Apple frameworks.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;iOS Dev Weekly&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://iosdevweekly.com/issues.rss&quot;&gt;https://iosdevweekly.com/issues.rss&lt;/a&gt;&lt;br&gt;
A long running weekly newsletter curated by Dave Verwer. It aggregates the best iOS development articles, tools, libraries, and discussions from across the community.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Kotlin Blog by JetBrains&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.jetbrains.com/kotlin/feed/&quot;&gt;https://blog.jetbrains.com/kotlin/feed/&lt;/a&gt;&lt;br&gt;
The official Kotlin blog by JetBrains. It covers Kotlin language evolution, Kotlin Multiplatform updates, compiler changes, and best practices relevant to Android and cross platform development.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ProMobile.Dev&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://promobile.dev/feed&quot;&gt;https://promobile.dev/feed&lt;/a&gt;&lt;br&gt;
A focused feed covering mobile development news, articles and tutorials across Android, iOS, and Kotlin Multiplatform.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Swift.org Blog&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.swift.org/atom.xml&quot;&gt;https://www.swift.org/atom.xml&lt;/a&gt;&lt;br&gt;
The official Swift language blog. It publishes proposals, language evolution updates, compiler changes, and announcements relevant to Swift developers on all platforms.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use Your Loaf&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://useyourloaf.com/blog/rss.xml&quot;&gt;https://useyourloaf.com/blog/rss.xml&lt;/a&gt;&lt;br&gt;
An iOS focused blog with deep dives into Apple frameworks, APIs, and platform behavior. Known for clear explanations and attention to technical detail.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Paul Hudson - Hacking with Swift&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.hackingwithswift.com/articles/rss&quot;&gt;https://www.hackingwithswift.com/articles/rss&lt;/a&gt;&lt;br&gt;
High quality Swift and iOS development content ranging from beginner to advanced topics, written clearly and consistently updated.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/9324/essential-rss-feeds-for-mobile-developers</guid>
<pubDate>Tue, 06 Jan 2026 21:55:48 +0000</pubDate>
</item>
<item>
<title>Type-safe Kotlin Multiplatform i18n: auto-convert Android strings to cross-platform translations.</title>
<link>https://coderlegion.com/6454/type-safe-kotlin-multiplatform-i18n-convert-android-strings-cross-platform-translations</link>
<description>&lt;p&gt;i18nKonfig — Zero-Config Internationalization for Kotlin Multiplatform&lt;/p&gt;
&lt;p&gt;Tired of juggling strings.xml, runtime crashes, and messy translation setups?&lt;br&gt;
I built i18nKonfig, a Gradle plugin that turns your Android strings into type-safe, multiplatform translations — automatically &lt;/p&gt;
&lt;p&gt; From this:&lt;br&gt;
getString(R.string.welcome_message, userName)&lt;/p&gt;
&lt;p&gt; To this:&lt;br&gt;
Text(I18nKonfig.welcome_message)&lt;/p&gt;
&lt;p&gt;Works across Android, iOS, Desktop &amp;amp; Web&lt;br&gt;
 Compile-time safety (no missing strings)&lt;br&gt;
 Auto-migrates your Android resources&lt;br&gt;
 Zero manual setup — just ./gradlew build&lt;/p&gt;
&lt;p&gt;I haven’t released it yet — just testing community interest.&lt;br&gt;
Would you use something like this in your KMP projects? Or even help shape the first release?&lt;/p&gt;
</description>
<category>Open Source Tools</category>
<guid isPermaLink="true">https://coderlegion.com/6454/type-safe-kotlin-multiplatform-i18n-convert-android-strings-cross-platform-translations</guid>
<pubDate>Tue, 14 Oct 2025 23:22:55 +0000</pubDate>
</item>
<item>
<title>ImagePickerKMP 1.0.23: Controling Camera Launch on iOS with directCameraLaunch</title>
<link>https://coderlegion.com/5137/imagepickerkmp-1-0-23-controling-camera-launch-on-ios-with-directcameralaunch</link>
<description>&lt;h2 id=&quot;imagepickerkmp-1-0-23-controling-camera-launch-on-ios-with-directcameralaunch&quot;&gt;ImagePickerKMP 1.0.23: Controling Camera Launch on iOS with &lt;code&gt;directCameraLaunch&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;The latest release of &lt;strong&gt;ImagePickerKMP (v1.0.23)&lt;/strong&gt; brings a small but&lt;br&gt;
powerful addition for iOS developers: a new flag called&lt;br&gt;
&lt;code&gt;directCameraLaunch&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This feature gives you control over whether the image picker should&lt;br&gt;
&lt;strong&gt;open directly into the camera&lt;/strong&gt; or show the &lt;strong&gt;source selection&lt;br&gt;
dialog&lt;/strong&gt; first.&lt;/p&gt;
&lt;h3 id=&quot;the-new-flag-directcameralaunch&quot;&gt;The New Flag: &lt;code&gt;directCameraLaunch&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;By default, the picker shows a dialog on iOS asking the user to choose&lt;br&gt;
between camera and gallery.\&lt;br&gt;
With &lt;code&gt;directCameraLaunch&lt;/code&gt;, you can now change that behavior:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;directCameraLaunch = true&lt;/code&gt; → Skips the dialog and opens the&lt;br&gt;
&lt;strong&gt;camera immediately&lt;/strong&gt;.\&lt;/li&gt;
&lt;li&gt;&lt;code&gt;directCameraLaunch = false&lt;/code&gt; → Keeps the &lt;strong&gt;dialog&lt;/strong&gt; so the user can&lt;br&gt;
choose.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;usage-example&quot;&gt;Usage Example&lt;/h3&gt;
&lt;p&gt;Here's how you can use it with &lt;code&gt;ImagePickerLauncher&lt;/code&gt; in your Compose&lt;br&gt;
Multiplatform project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;    ImagePickerLauncher(
        config = ImagePickerConfig(
            onPhotoCaptured = { result -&amp;gt;
                capturedPhoto = result
                showCamera = false
            },
            onError = {
                showCamera = false
            },
            onDismiss = {
                showImagePicker = false // Reset state when user doesn't select anything
            },
            directCameraLaunch = false // Set to true if you want to launch the camera directly Only IOS
        )
    )

&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;why-this-matters&quot;&gt;Why This Matters&lt;/h3&gt;
&lt;p&gt;This new option provides &lt;strong&gt;better UX flexibility&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Apps that want a streamlined flow (e.g., scanning receipts, IDs, QR&lt;br&gt;
codes) can go straight to the camera.\&lt;/li&gt;
&lt;li&gt;Apps that want to offer both gallery and camera selection can keep&lt;br&gt;
the dialog.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In short: you now have the best of both worlds, controlled with a simple&lt;br&gt;
flag.&lt;/p&gt;
&lt;h3 id=&quot;dependency&quot;&gt;Dependency&lt;/h3&gt;
&lt;p&gt;To try it out, update your Gradle dependency to the latest version:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;dependencies {
    implementation(&quot;io.github.ismoy:imagepickerkmp:1.0.23&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;directCameraLaunch&lt;/code&gt; flag makes ImagePickerKMP even more versatile&lt;br&gt;
for real-world use cases. Whether your app prioritizes fast camera&lt;br&gt;
access or user choice, you can now support both seamlessly with version&lt;br&gt;
&lt;strong&gt;1.0.23&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Are you planning to use &lt;code&gt;directCameraLaunch&lt;/code&gt; in your project? Would you&lt;br&gt;
prefer the dialog or direct camera launch by default?&lt;/p&gt;
&lt;p&gt;Github: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ismoy/ImagePickerKMP&quot;&gt;https://github.com/ismoy/ImagePickerKMP&lt;/a&gt;&lt;/p&gt;
</description>
<category>Building Open Source Projects</category>
<guid isPermaLink="true">https://coderlegion.com/5137/imagepickerkmp-1-0-23-controling-camera-launch-on-ios-with-directcameralaunch</guid>
<pubDate>Wed, 03 Sep 2025 05:56:05 +0000</pubDate>
</item>
</channel>
</rss>