<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Avik Sharma Chowdhury</title>
    <description>The latest articles on DEV Community by Avik Sharma Chowdhury (@metalhead_coder).</description>
    <link>https://dev.to/metalhead_coder</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3007646%2F2e369ed5-084b-4b4d-9467-a0c70fd2a062.png</url>
      <title>DEV Community: Avik Sharma Chowdhury</title>
      <link>https://dev.to/metalhead_coder</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/metalhead_coder"/>
    <language>en</language>
    <item>
      <title>How I Built Free and Full Build Flavors for Android in My KMP Codebase</title>
      <dc:creator>Avik Sharma Chowdhury</dc:creator>
      <pubDate>Fri, 02 Oct 2026 10:55:07 +0000</pubDate>
      <link>https://dev.to/metalhead_coder/how-i-built-free-and-full-build-flavors-for-android-in-my-kmp-codebase-2n7o</link>
      <guid>https://dev.to/metalhead_coder/how-i-built-free-and-full-build-flavors-for-android-in-my-kmp-codebase-2n7o</guid>
      <description>&lt;h4&gt;
  
  
  Part 3 of my KMP series: how Android product flavors let the free version of my app leave the sign-in, sync and payment code out of the build
&lt;/h4&gt;

&lt;p&gt;In &lt;a href="https://avik-sharma-chy.medium.com/from-idea-to-google-play-the-challenges-of-my-first-kmp-app-211d948af33b" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt;, I shared how my plan changed after completing the closed testing with MVP. I decided to launch the app for free and keep the paid features (sign-in, cloud sync and a premium subscription) ready for later. In &lt;a href="https://avik-sharma-chy.medium.com/how-i-structured-my-first-kmp-app-modules-layers-and-source-sets-43cda8cbebf6" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt;, I walked through how the project is organized.&lt;/p&gt;

&lt;p&gt;This part is about how one codebase builds both versions: a free app with only the core features, and a full app with everything. I’ll cover product flavors, how the free app leaves the premium features out, and how the shared code works with either version without knowing which one it’s in.&lt;/p&gt;

&lt;p&gt;One note before we start: product flavors are an Android feature, and everything in this part is about the Android app. On iOS, I plan to release the full version, so the iOS code simply includes all the features.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Two Builds?
&lt;/h3&gt;

&lt;p&gt;My first attempt was introducing a feature flag that hid the premium features. The free version still included the &lt;strong&gt;Auth&lt;/strong&gt; and &lt;strong&gt;Payment&lt;/strong&gt; SDKs and still asked for the billing permission, even though no one can buy anything.&lt;/p&gt;

&lt;p&gt;I wanted the free app to contain only what it uses. That meant building two different apps from the same code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Product Flavors
&lt;/h3&gt;

&lt;p&gt;A product flavor is Android’s way of building different versions of an app from one codebase. You define flavors in Gradle, and each flavor can have its own code, resources and dependencies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;android&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;flavorDimensions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"environment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"distribution"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="nf"&gt;productFlavors&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;dimension&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"environment"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"prod"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;dimension&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"environment"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"free"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;dimension&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"distribution"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"full"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;dimension&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"distribution"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flavors are grouped into two dimensions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;environment (dev / prod):&lt;/strong&gt; a development version and a release version. The dev build has a different app ID, so both can be installed on the same phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;distribution (free / full):&lt;/strong&gt; which features the app includes. The free flavor has only the core features, and full flavor includes sign-in, sync and payments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Gradle combines them into variants such as &lt;strong&gt;prodFreeRelease&lt;/strong&gt;, which is the app on Google Play, and &lt;strong&gt;devFullDebug&lt;/strong&gt;, which I use to work on the premium features.&lt;/p&gt;

&lt;h3&gt;
  
  
  Leaving Auth and Payment Code Out
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;sign-in&lt;/strong&gt; and &lt;strong&gt;sync&lt;/strong&gt; code lives in an &lt;strong&gt;auth&lt;/strong&gt; module, and the payment code lives in a payment module. Both are added only to the full flavor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Only the full build includes sign-in, sync and payments&lt;/span&gt;
    &lt;span class="s"&gt;"fullImplementation"&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;":libs:auth"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="s"&gt;"fullImplementation"&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;":libs:payment"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;fullImplementation&lt;/strong&gt; means only include this in builds of the full flavor. The free app on Google Play doesn’t contain the sign-in, sync or payment code, or the SDKs behind them.&lt;/p&gt;

&lt;p&gt;The billing permission works the same way. It’s declared in a small manifest file inside the androidFull folder, so only the full app asks for it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhfl4pzdjloy0g2255mrm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhfl4pzdjloy0g2255mrm.png" alt="Diagram comparing the free and full builds. In the free build, composeApp depends only on domain-api (shared interfaces), and the auth and payment modules are not included. In the full build, composeApp depends on auth (sign-in and sync with Supabase), payment (subscriptions with RevenueCat) and domain-api, and auth and payment also depend on domain-api." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The free build leaves the sign-in, sync and payment modules out entirely. The full build plugs them in behind the same interfaces.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Hiding the SDKs Behind Interfaces
&lt;/h3&gt;

&lt;p&gt;The shared code never uses the auth or payment modules directly. It only uses interfaces. These interfaces live in a small module called &lt;strong&gt;domain-api&lt;/strong&gt;. This module holds only interfaces and simple data classes, with no SDKs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;AuthRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;currentUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Flow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;logoutUser&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shared code depends on these interfaces. The auth and payment modules implement them with Supabase and RevenueCat. The free build has its own simple versions that do nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Picking the Right Code at Startup
&lt;/h3&gt;

&lt;p&gt;Something still has to decide which versions of the interfaces the app uses. This is where the flavor source sets come in. Each flavor has its own folder, &lt;strong&gt;androidFree&lt;/strong&gt; and &lt;strong&gt;androidFull&lt;/strong&gt;, and only one of them is compiled into a build.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;commonMain&lt;/strong&gt;, there's a small interface for whatever cloud setup the build has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;CloudBootstrap&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onKoinStarted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;koin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Koin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each flavor folder defines a value with the same name, cloudBootstrap. In &lt;strong&gt;androidFree&lt;/strong&gt;, it loads &lt;strong&gt;freeCloudModule&lt;/strong&gt;. This module has &lt;strong&gt;no-op&lt;/strong&gt; (no operation) versions of the sign-in, sync and payment interfaces.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cloudBootstrap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CloudBootstrap&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="err"&gt;: &lt;/span&gt;&lt;span class="nc"&gt;CloudBootstrap&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;freeCloudModule&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The free version doesn’t override onKoinStarted. The interface already gives it an empty default, so in the free build that call does nothing.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;androidFull&lt;/strong&gt;, it loads the real modules and sets up payments once Koin has started:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cloudBootstrap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CloudBootstrap&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="err"&gt;: &lt;/span&gt;&lt;span class="nc"&gt;CloudBootstrap&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authCloudModule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;paymentCloudModule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fullAndroidModule&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onKoinStarted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;koin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Koin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;PaymentInitializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initializeAndroid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;koin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app’s startup code in &lt;strong&gt;androidMain&lt;/strong&gt; uses cloudBootstrap without knowing which one it got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;koin&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;startKoin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;androidContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="nd"&gt;@MyApplication&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;androidModule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sharedModule&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;cloudBootstrap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="n"&gt;koin&lt;/span&gt;

&lt;span class="n"&gt;cloudBootstrap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onKoinStarted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;koin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The free build’s module provides the same interfaces with fakes and no-ops.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;freeCloudModule&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;module&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;single&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AuthRepository&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;FakeAuthRepository&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;single&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RemoteSyncGateway&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;NoOpSyncGatewayImpl&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;single&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SubscriptionRepository&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;FakeSubscriptionRepository&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;single&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PaywallHost&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;NoOpPaywallHost&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A Safety Net for the Free Build
&lt;/h3&gt;

&lt;p&gt;The free build should never include the auth and payment code. One wrong line in a Gradle file could bring it back, and the app would still build without any error. To catch that, I added a check to CI.&lt;/p&gt;

&lt;p&gt;On every pull request, GitHub Actions lists every library in the free release build and fails if one of the full-only SDKs shows up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Verify the free flavor has no paid SDKs&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;./gradlew -q :composeApp:dependencies --configuration prodFreeReleaseRuntimeClasspath &amp;gt; free-deps.txt&lt;/span&gt;
    &lt;span class="s"&gt;if grep -qiE "com\.revenuecat|io\.github\.jan|io\.ktor|project :libs:auth|project :libs:payment" free-deps.txt; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "::error::A paid SDK leaked into the free flavor's classpath"&lt;/span&gt;
      &lt;span class="s"&gt;exit 1&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Lessons Learned
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A feature flag hides a feature.&lt;/strong&gt; A flavor-only dependency removes it. If you don’t want code in an app, keep it out of the build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep third-party types out of your interfaces.&lt;/strong&gt; Convert them into your own types inside the module that uses the library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give shared code interfaces, and let each build choose the implementation.&lt;/strong&gt; The shared code stays the same for both versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a CI check for every rule you care about.&lt;/strong&gt; It’s easy to break a structure rule without noticing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Wrapping Up
&lt;/h3&gt;

&lt;p&gt;Building this app taught me more about Kotlin Multiplatform than any tutorial did. Most of the lessons came from real problems, like reorganizing the project as it grew, keeping SDKs out of the shared code, and fixing an iOS build that broke without warning. I hope learning about these pitfalls saves you some time on your own project.&lt;/p&gt;

&lt;p&gt;I might write more about the app later, like offline sync or testing in-app purchases. If you’d like to see that, follow me here on Medium. And if you’re working on a KMP app yourself, I’d love to hear what you’ve learned in the comments.&lt;/p&gt;

&lt;p&gt;Thanks for reading! If you’re preparing for the “Leben in Deutschland” test, you can try the app on &lt;a href="https://play.google.com/store/apps/details?id=org.metalheadcoder.lebenindeutschland" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://avik-sharma-chy.medium.com/list/building-an-exam-prep-app-with-kotlin-multiplatform-8f79a357f3d2" rel="noopener noreferrer"&gt;See all parts of the series on Medium&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kmp</category>
      <category>kotlin</category>
      <category>android</category>
      <category>gradle</category>
    </item>
    <item>
      <title>How I Structured My First KMP App: Modules, Layers and Source Sets</title>
      <dc:creator>Avik Sharma Chowdhury</dc:creator>
      <pubDate>Fri, 02 Oct 2026 10:20:33 +0000</pubDate>
      <link>https://dev.to/metalhead_coder/how-i-structured-my-first-kmp-app-modules-layers-and-source-sets-49hb</link>
      <guid>https://dev.to/metalhead_coder/how-i-structured-my-first-kmp-app-modules-layers-and-source-sets-49hb</guid>
      <description>&lt;h4&gt;
  
  
  A walkthrough of the modules, source sets and Gradle setup behind a Kotlin Multiplatform app on Google Play.
&lt;/h4&gt;

&lt;p&gt;Part 2 of “Building an Exam Prep App with Kotlin Multiplatform”. &lt;a href="https://medium.com/@avik-sharma-chy/from-idea-to-google-play-the-challenges-of-my-first-kmp-app-211d948af33b" rel="noopener noreferrer"&gt;Part 1: From Idea to Google Play: The Challenges of My First KMP App&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Part 1, I shared the story of my first Kotlin Multiplatform app: why I built it, how the plan changed after the MVP, and how I ended up shipping a free version while keeping the paid features ready for later.&lt;/p&gt;

&lt;p&gt;In this article, I’ll walk through how the project is organized: how the modules fit together, how the code is layered, which code is shared, and which code is platform-specific. I’ll include some code from the project along the way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Project Overview
&lt;/h3&gt;

&lt;p&gt;The project is split into a few Gradle modules. Each module has its own build file and its own dependencies, and it can only use code from the modules it depends on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LebenInDeutschland/
├── composeApp/ main app module
├── iosApp/ Xcode project that launches the iOS app
├── libs/ library modules
│ ├── domain-api/ shared interfaces and models
│ ├── auth/ sign-in and cloud sync (Supabase)
│ ├── payment/ subscriptions (RevenueCat)
│ └── telemetry-core/ crash and analytics logging
└── build-logic/ shared Gradle setup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;composeApp&lt;/strong&gt; is the main module. It holds the screens, ViewModels, business logic and local database. Most of that lives in &lt;strong&gt;commonMain&lt;/strong&gt;, and each platform folder only holds what that platform needs. On Android, this module builds the app itself. On iOS, it builds a framework that the Xcode project in &lt;strong&gt;iosApp&lt;/strong&gt; loads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;libs/&lt;/strong&gt; holds four smaller modules, each with its own build file. &lt;strong&gt;domain-api&lt;/strong&gt; has only interfaces and data classes, with no SDKs. &lt;strong&gt;auth&lt;/strong&gt; and &lt;strong&gt;payment&lt;/strong&gt; implement those interfaces using Supabase and RevenueCat. &lt;strong&gt;telemetry-core&lt;/strong&gt; wraps crash reporting and analytics.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3mu1ofp4gy5q7edyd3jr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3mu1ofp4gy5q7edyd3jr.png" alt="Module diagram: composeApp depends on domain-api and telemetry-core. composeApp depends on auth and payment in the full build only. auth (sign-in and cloud sync with Supabase) and payment (subscriptions and paywall with RevenueCat) both depend on domain-api, which holds shared interfaces and models with no SDKs." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The app module depends on the shared interfaces in domain-api. The auth and payment modules implement those interfaces, and only the full build flavor includes them.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  App Module Layers
&lt;/h3&gt;

&lt;p&gt;Inside composeApp, the code follows Clean Architecture. That’s a way of organizing code into layers, where each layer has one job and only depends on the layers below it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;presentation&lt;/strong&gt;: screens (Compose UI) and ViewModels. A ViewModel holds the screen’s state and reacts to what the user does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;domain&lt;/strong&gt;: the rules of the app. For example, &lt;strong&gt;CalculateReadinessUseCase&lt;/strong&gt; decides how ready you are for the exam based on your progress. This layer uses plain Kotlin and has no UI or database code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;data&lt;/strong&gt;: where the data comes from. The local Room database (Room is Android’s database library, which now also works on iOS in KMP), the question file, and the repositories that combine them.
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;composeApp/src/commonMain/kotlin/.../
├── data/ local database, repositories, sync
├── domain/ models, repository interfaces, use cases
├── presentation/ screens and ViewModels, one folder per feature
├── di/ dependency injection setup
└── util/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Horizontal vs Vertical Slicing
&lt;/h3&gt;

&lt;p&gt;A question I was asked in a recent interview: is your project sliced horizontally or vertically? It’s a useful way to describe a structure, so here is what the two mean and where my app fits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Horizontal slicing&lt;/strong&gt; (also called &lt;strong&gt;&lt;em&gt;package by layer&lt;/em&gt;&lt;/strong&gt;) groups code by its technical layer first. There is one data folder, one domain folder and one presentation folder, and every feature adds its files to each of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;data/
  repository/ QuestionRepositoryImpl, ProgressRepositoryImpl, ExamHistoryRepositoryImpl
domain/
  repository/ QuestionRepository, ProgressRepository, ExamHistoryRepository
  usecase/ CalculateReadinessUseCase
presentation/
  exam/ ExamScreen, ExamViewModel
  study/ StudyScreen, StudyViewModel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Vertical slicing&lt;/strong&gt; (also called &lt;strong&gt;&lt;em&gt;package by feature&lt;/em&gt;&lt;/strong&gt;) groups code by feature first. Each feature gets its own folder, or its own module, with all three layers inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;exam/
  data/ ExamHistoryRepositoryImpl
  domain/ ExamHistoryRepository
  presentation/ ExamScreen, ExamViewModel
study/
  data/ ProgressRepositoryImpl
  domain/ ProgressRepository
  presentation/ StudyScreen, StudyViewModel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first example is how my app module is organized, so it follows horizontal slicing. The presentation layer has one folder per feature, but &lt;strong&gt;data&lt;/strong&gt; and &lt;strong&gt;domain&lt;/strong&gt; are grouped by type (models, repositories, use cases). The second example shows how the same files would look if I had sliced the app vertically.&lt;/p&gt;

&lt;p&gt;I chose this because the app is small, and most features share the same data. The exam, study and profile screens all read the same questions and the same progress. In a vertical structure, I’d have to decide which feature owns the progress data, or create a shared “core” module that most features depend on anyway.&lt;/p&gt;

&lt;p&gt;Horizontal slicing has trade-offs too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A change to one feature touches several folders.&lt;/strong&gt; Adding a field to the exam results means editing data, domain and presentation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature boundaries are not enforced.&lt;/strong&gt; Nothing stops the study screen from using something meant only for the exam screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It’s harder to split up later.&lt;/strong&gt; In a large app with several teams, vertical slices let each team own a feature and build it on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My modules are a mix. Inside &lt;strong&gt;composeApp&lt;/strong&gt;, the code is sliced horizontally. But &lt;strong&gt;auth&lt;/strong&gt; and &lt;strong&gt;payment&lt;/strong&gt; are separate modules, each owning one feature from its outside service up to the interfaces it provides. I didn’t split them out to follow vertical slicing. I split them out so the free build could leave them out. Still, it shows that the two approaches can live together: horizontal where features share most of their code, and separate modules where a feature needs a clear boundary.&lt;/p&gt;

&lt;p&gt;If the app grows, the first step I’d take is to move each screen group (exam, study, profile) into its own feature module (vertical slicing), with a shared core module for the questions and progress data.&lt;/p&gt;

&lt;h3&gt;
  
  
  App Module Source Sets
&lt;/h3&gt;

&lt;p&gt;Layers split the code by &lt;em&gt;job&lt;/em&gt;. Source sets split it by &lt;em&gt;platform&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A source set is a folder of code that is compiled for a specific set of targets. In a KMP project, the code you write in &lt;strong&gt;&lt;em&gt;commonMain&lt;/em&gt;&lt;/strong&gt; is compiled for every platform, while &lt;strong&gt;&lt;em&gt;androidMain&lt;/em&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;em&gt;iosMain&lt;/em&gt;&lt;/strong&gt; are compiled only for their platform.&lt;/p&gt;

&lt;p&gt;My app module has these source sets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;composeApp/src/
├── commonMain/ shared by Android and iOS (most of the app)
├── androidMain/ Android only
├── iosMain/ iOS only
├── androidFree/ Android, free build only
├── androidFull/ Android, full build only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Shared Code in commonMain
&lt;/h3&gt;

&lt;p&gt;Almost the whole app lives in &lt;strong&gt;commonMain:&lt;/strong&gt; screens, ViewModels, exam logic, Room database, question data, navigation and localization. For DI, I used Koin, a DI library for Kotlin. Here is part of the shared Koin module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;sharedModule&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;module&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Repositories&lt;/span&gt;
    &lt;span class="n"&gt;single&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;QuestionRepository&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;QuestionRepositoryImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;single&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProgressRepository&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;ProgressRepositoryImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Use cases&lt;/span&gt;
    &lt;span class="nf"&gt;factoryOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;CalculateReadinessUseCase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// ViewModels&lt;/span&gt;
    &lt;span class="nf"&gt;viewModelOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;ExamViewModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;viewModelOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;StudyViewModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Testing the Koin Setup
&lt;/h4&gt;

&lt;p&gt;Koin works differently from Hilt, another popular DI library for Android. Hilt checks the dependency graph when you build the app. If a class needs something that nobody provides, the build fails. Koin connects everything while the app runs. A missing binding still compiles fine, and the app crashes only when a screen first asks for it. That could happen on a user’s phone.&lt;/p&gt;

&lt;p&gt;To catch this early, I added a unit test that initializes the Koin configuration and verifies the dependency graph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@RunWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RobolectricTestRunner&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;KoinGraphTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@After&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;tearDown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stopKoin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;everyBindingResolves&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;koin&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;startKoin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;androidContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ApplicationProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getApplicationContext&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="nf"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;androidModule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sharedModule&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="n"&gt;koin&lt;/span&gt;

        &lt;span class="n"&gt;koin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;QuestionRepository&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
        &lt;span class="n"&gt;koin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ExamViewModel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
        &lt;span class="n"&gt;koin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StudyViewModel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
        &lt;span class="n"&gt;koin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SettingsViewModel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test uses Robolectric, a library that simulates the Android environment, so it can run as a normal unit test on your computer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Platform Code with expect and actual
&lt;/h3&gt;

&lt;p&gt;Some things only the platform can do. Asking a user to rate the app is one of them. Android uses Google Play’s In-App Review API, while iOS uses StoreKit. Kotlin handles this with &lt;strong&gt;expect&lt;/strong&gt; and &lt;strong&gt;actual&lt;/strong&gt;. You declare something in &lt;strong&gt;commonMain&lt;/strong&gt; with &lt;strong&gt;expect&lt;/strong&gt; and each platform provides its own version with &lt;strong&gt;actual&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// in commonMain&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;AppReviewController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;requestReview&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;rememberAppReviewController&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;AppReviewController&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;strong&gt;androidMain&lt;/strong&gt;, the Android version asks Google Play to show the prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;rememberAppReviewController&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;AppReviewController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocalContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;AndroidAppReviewController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AndroidAppReviewController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AppReviewController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;requestReview&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Android-specific in-app review code&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;strong&gt;iosMain&lt;/strong&gt;, the iOS version asks StoreKit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;rememberAppReviewController&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;AppReviewController&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nf"&gt;remember&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;IosAppReviewController&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IosAppReviewController&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AppReviewController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;requestReview&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// iOS-specific in-app review code&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The screen that shows the prompt after a passed exam lives in commonMain, and it doesn’t know which platform it’s running on.&lt;/p&gt;

&lt;p&gt;A pattern I ended up using a lot: keep the expect part small. Here it’s one function, and the rest is a normal interface. In tests, I can replace the interface with a fake, so the shared code stays easy to test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wrapping Up
&lt;/h3&gt;

&lt;p&gt;Most of my app lives in commonMain, and each platform only adds the few things it can do on its own. Layers keep the code organized by job, source sets keep it organized by platform, and a few separate modules keep the outside services apart.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://avik-sharma-chy.medium.com/how-i-built-free-and-full-build-flavors-for-android-in-my-kmp-codebase-511bffdcc28c" rel="noopener noreferrer"&gt;Part 3&lt;/a&gt;, I cover product flavors: how the same codebase builds a free and a full version of the app, and how the free version leaves the sign-in, sync and payment code out entirely.&lt;/p&gt;

&lt;p&gt;Thanks for reading! If you’re preparing for the “Leben in Deutschland” test, you can try the app on &lt;a href="https://play.google.com/store/apps/details?id=org.metalheadcoder.lebenindeutschland" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://avik-sharma-chy.medium.com/list/building-an-exam-prep-app-with-kotlin-multiplatform-8f79a357f3d2" rel="noopener noreferrer"&gt;See all parts of the series on Medium&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>android</category>
      <category>ios</category>
      <category>kmp</category>
    </item>
    <item>
      <title>From Idea to Google Play: The Challenges of My First KMP App</title>
      <dc:creator>Avik Sharma Chowdhury</dc:creator>
      <pubDate>Thu, 01 Oct 2026 18:52:18 +0000</pubDate>
      <link>https://dev.to/metalhead_coder/from-idea-to-google-play-the-challenges-of-my-first-kmp-app-17bi</link>
      <guid>https://dev.to/metalhead_coder/from-idea-to-google-play-the-challenges-of-my-first-kmp-app-17bi</guid>
      <description>&lt;h4&gt;
  
  
  &lt;em&gt;The story behind an exam prep app built with Kotlin Multiplatform and Compose Multiplatform&lt;/em&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://avik-sharma-chy.medium.com/list/building-an-exam-prep-app-with-kotlin-multiplatform-8f79a357f3d2" rel="noopener noreferrer"&gt;Part 1 of the series “Building an Exam Prep App with Kotlin Multiplatform”&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;More and more companies are adopting Kotlin Multiplatform (KMP) for their mobile apps. The main motivation is to share business logic across platforms.&lt;/p&gt;

&lt;p&gt;If you’re a mobile developer working on a product with separate native Android and iOS apps, you’ve probably noticed inconsistencies between the two, in business logic, UI/UX and other areas. These differences are often hard to remove once they exist.&lt;/p&gt;

&lt;p&gt;One way to avoid them is to share the same business logic and UI across both platforms, and keep only the platform-specific parts native. That’s where KMP and Compose Multiplatform (CMP) shine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who This App Is For
&lt;/h3&gt;

&lt;p&gt;A few months ago, I took the “Leben in Deutschland” test (“Life in Germany”). It’s a required step when you apply for permanent residency or citizenship in Germany. While preparing, I tried several apps and websites, but none of them really fit what I needed. So I decided to build my own.&lt;/p&gt;

&lt;p&gt;The result is &lt;a href="https://play.google.com/store/apps/details?id=org.metalheadcoder.lebenindeutschland" rel="noopener noreferrer"&gt;&lt;strong&gt;Leben in DE: Einbürgerungstest&lt;/strong&gt;&lt;/a&gt;, a free Android app with all 460 official questions, mock exams in the real format, and localization. It works offline and doesn’t need an account.&lt;/p&gt;

&lt;p&gt;This article is the story behind it, plus the lessons and tips I picked up along the way.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp52e7xbc7o9qndsnc0se.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp52e7xbc7o9qndsnc0se.png" alt="Study screen showing an official test question in German with an English translation under the question and each answer. The correct answer is highlighted in green." width="800" height="1778"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Study mode: each question and answer with an English translation&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu170lerkxey2631s0ya7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu170lerkxey2631s0ya7.png" alt="Vocabulary Refresher panel open over a question, listing German words from the question with their English meanings, such as " width="800" height="1778"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Vocabulary Refresher: key German words from the question&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faw3zsa2w58oqt3x66zyg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faw3zsa2w58oqt3x66zyg.png" alt="Settings screen with options for appearance (System, Light, Dark), app theme (Indigo, Obsidian, Sage), language, federal state (Berlin selected) and haptic feedback." width="800" height="1778"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Settings: themes, language and your federal state&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why I Built It
&lt;/h3&gt;

&lt;p&gt;The reason is twofold.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First&lt;/strong&gt;, to learn Kotlin Multiplatform and get hands-on experience by building and shipping a real consumer-facing app. I read a lot of blogs and docs on KMP, watched tutorials on YouTube; however, none of it really clicked until I started building things on my own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Second&lt;/strong&gt;, to build something of my own: from planning to execution to shipping the product.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Journey in Short
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;February 2026:&lt;/strong&gt; registered for the exam, planned the app and set up the project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;March:&lt;/strong&gt; built the CI/CD pipeline (automated builds on every change): unit tests, signed release builds, and the other plumbing a real release needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;April:&lt;/strong&gt; built the first &lt;strong&gt;MVP&lt;/strong&gt; (minimum viable product) with the core features: practice questions, mock exams and progress tracking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;May:&lt;/strong&gt; added sign-in and cloud sync with &lt;strong&gt;Supabase&lt;/strong&gt; (a hosted Postgres database with a login service), and subscriptions with &lt;strong&gt;RevenueCat&lt;/strong&gt; (a service that handles in-app purchases with Google Play and the App Store). Everything worked end to end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;June:&lt;/strong&gt; ran a closed test on Google Play with a small group of testers. I also used the app a lot to prepare for my own exam. The short mock exams were especially useful, and I ended up scoring 100%. :)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;July:&lt;/strong&gt; based on what I learned, decided to launch for free and split the app into two versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;August:&lt;/strong&gt; did the split. The free version became lean, and sign-in, sync and subscriptions moved into a separate “full” version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;September:&lt;/strong&gt; ran a few rounds of internal testing, then released the free version on Google Play.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Rethinking the Release Plan after the MVP
&lt;/h3&gt;

&lt;p&gt;My original plan was a free app with a paid tier. Paying users would get sign-in, sync across devices and a few handy extra features.&lt;/p&gt;

&lt;p&gt;I already knew my target audience: expats living in Germany who were preparing for the exam. What I didn’t know was whether they’d find it useful, which features they’d rely on, or whether any of them would pay for sync. So I decided to launch completely free and revisit the premium idea later, with real data.&lt;/p&gt;

&lt;h4&gt;
  
  
  First attempt: a feature flag
&lt;/h4&gt;

&lt;p&gt;My first move was a single feature flag in the shared Kotlin code: one true/false value that decides whether the paid features exist. Because it was a compile-time constant, the compiler could strip out any code behind it when it was off. With the flag off, the app never started Supabase or RevenueCat, and all sign-in and premium screens disappeared.&lt;/p&gt;

&lt;p&gt;From a user’s point of view, this worked. Under the surface it got messy. The Supabase, RevenueCat and Google Sign-In libraries were still packed inside the app, which made the app size bigger. The app still asked for Google Play’s billing permission even though it sold nothing. And the checks were spread across several screens, so each new feature meant another check to remember.&lt;/p&gt;

&lt;h4&gt;
  
  
  Second attempt: separate modules and two builds
&lt;/h4&gt;

&lt;p&gt;I moved sign-in, sync and payments into their own modules, which are separate parts of the project that can be included in a build or left out. Then I set up two product flavors for building different versions of an app from the same code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;free&lt;/strong&gt;: the current version on Google Play. It doesn’t include the sign-in, sync or payment modules at all, so the app is smaller and contains only what it uses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;full&lt;/strong&gt;: everything, including sign-in, cloud sync and subscriptions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhfl4pzdjloy0g2255mrm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhfl4pzdjloy0g2255mrm.png" alt="Diagram comparing the free and full builds. In the free build, composeApp depends only on domain-api (shared interfaces), and the auth and payment modules are not included. In the full build, composeApp depends on auth (sign-in and sync with Supabase), payment (subscriptions with RevenueCat) and domain-api, and auth and payment also depend on domain-api." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;High-level architecture of the free and full flavors&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What I Learned
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Learn who your users are before you charge them.&lt;/strong&gt; I built sync and payments before I had a single user. The work isn’t lost, since it lives on in the full build. However, launching free earlier would have taught me more, sooner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hiding a feature and removing it are different things.&lt;/strong&gt; A feature flag can hide a feature from users, but its code still ships inside the app. To leave code out of a build, you need to separate it at the build level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrap third-party libraries in your own types.&lt;/strong&gt; Let only one small part of your app talk to a library directly. That part converts the library’s data into simple types you define, and the rest of the app only uses those. In my app, RevenueCat’s own data type had spread across several screens. Before I could move payments into a separate module, I had to replace it everywhere with my own type that answers one question: does this user have premium? Now that conversion happens in one place inside the payment module, and switching payment providers would mean changing only that one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KMP let me share most of the app, and the platform-specific parts took the most time.&lt;/strong&gt; Screens, logic, the question data and the local database are all shared. Sign-in, billing setup and crash reporting needed separate code for each platform, and they took longer than I planned.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Tips for Your First KMP App
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Working with Android and iOS
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Android Studio covers most of the work.&lt;/strong&gt; I wrote nearly all the code there, and I could build and run the iOS app from Android Studio too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some things need Xcode.&lt;/strong&gt; I opened Xcode, Apple’s development tool, to set up signing so the app would run on my own iPhone, and to add iOS-only libraries like Firebase through Swift Package Manager (Apple’s built-in tool for adding libraries). You’ll also need a Mac for any iOS work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build both platforms often.&lt;/strong&gt; If you mostly build for Android day to day, the iOS build can break without you noticing. It happened to me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with everything in shared code.&lt;/strong&gt; Only write platform-specific code when you have to. KMP handles this with &lt;code&gt;expect&lt;/code&gt; and &lt;code&gt;actual&lt;/code&gt;: shared code declares that it expects something to exist, and each platform provides the actual version.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Builds and Dependencies
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep all library versions in one file.&lt;/strong&gt; Gradle’s version catalog (&lt;code&gt;libs.versions.toml&lt;/code&gt;) lists every library and its version in one place, so every module uses the same versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adopt Gradle convention plugins early if your project has several modules.&lt;/strong&gt; Convention plugins are small plugins you write yourself to hold the dependencies your modules share, so each module’s build file stays short and consistent. I will write more on this in the project structure article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect upgrades to come in groups.&lt;/strong&gt; In KMP, the Kotlin version, Compose Multiplatform and some build tools need to match each other. Upgrading one can require upgrading the others, so I upgrade in small steps and read the release notes first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep secrets out of your repository.&lt;/strong&gt; My API keys, the app signing key and the Firebase config file (&lt;code&gt;google-services.json&lt;/code&gt;) are stored as GitHub Actions secrets (encrypted values that only the automated builds can read). The build pulls them in when it runs, so none of them is ever committed to the code.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Automated Builds (CI)
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set up CI early.&lt;/strong&gt; CI (continuous integration) means your project is built and checked automatically every time you push code. I added GitHub Actions in the second week. Every pull request runs the unit tests, measures code coverage and posts the result as a comment on the pull request, runs Android Lint to catch common mistakes, and checks that the free build contains no paid libraries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use branch names to trigger releases.&lt;/strong&gt; When I push a branch whose name starts with &lt;code&gt;release/&lt;/code&gt;, CI runs the tests and then builds a signed app bundle, the file you upload to Google Play. A branch starting with &lt;code&gt;build-apk/&lt;/code&gt; gives me a signed APK instead, which I can install directly on a test phone. To prepare a release, I create a branch like &lt;code&gt;release/1.4&lt;/code&gt;, push it, and a few minutes later the signed file is ready to download.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn important rules into CI checks.&lt;/strong&gt; My free build must not contain any payment or sync libraries, so CI lists the free build’s dependencies on every change and fails if any of them appears.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Shipping and Testing
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add crash reporting early.&lt;/strong&gt; I added Firebase Crashlytics to both platforms in the second month. It was helpful to get more insights during internal and closed testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test purchases without paying.&lt;/strong&gt; Google Play lets you add license testers: accounts that can go through the whole purchase flow without being charged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan for database changes.&lt;/strong&gt; When you change the structure of your local database, write a migration so users keep their progress after an update. My database has changed three times so far.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep notes as you go.&lt;/strong&gt; I keep a document in the project explaining why I made each decision. It has saved me a lot of time when I came back to code weeks later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Closing Thoughts
&lt;/h3&gt;

&lt;p&gt;Thanks for reading this far. :)&lt;/p&gt;

&lt;p&gt;This project did what I hoped it would. I learned Kotlin Multiplatform by shipping a real app, and I built something of my own that people can use today. If you’re preparing for the “Leben in Deutschland” test, or know someone who is, I’d be glad if you gave &lt;a href="https://play.google.com/store/apps/details?id=org.metalheadcoder.lebenindeutschland" rel="noopener noreferrer"&gt;&lt;strong&gt;Leben in DE: Einbürgerungstest&lt;/strong&gt;&lt;/a&gt; a try. Next, I plan to dive deeper into the key product decisions behind the app, with code from the project and the trade-offs behind each choice.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Next in the series: &lt;a href="https://avik-sharma-chy.medium.com/how-i-structured-my-first-kmp-app-modules-layers-and-source-sets-43cda8cbebf6" rel="noopener noreferrer"&gt;Part 2, How I Structured My First KMP App: Modules, Layers and Source Sets.&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://avik-sharma-chy.medium.com/list/building-an-exam-prep-app-with-kotlin-multiplatform-8f79a357f3d2" rel="noopener noreferrer"&gt;See all parts of the series on Medium&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kmp</category>
      <category>kotlin</category>
      <category>android</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
