Building Scalable Kotlin Multiplatform Apps Like a Chef

Architecting KMP apps is like running a professional kitchen – you need proper separation between stations (layers), quality ingredient sourcing (dependencies), and strict hygiene protocols (testing). Let’s explore how MVI, Koin, and modern tools create apps that scale gracefully across platforms.

The Layered Kitchen Approach

Every great app needs clear station separation:

LayerResponsibilityToolsTesting Approach
UIPresentationComposeState assertions
DomainBusiness logicKoinPure unit tests
DataNetworking/DBKtor, SQLDelightIntegration tests
PlatformDevice accessExpect/ActualMocked scenarios
// Data layer module
val dataModule = module {
    single<ApiService> { KtorApiClient(get()) }
    single<Database> { SqlDelightDatabase(get()) }
}

// Domain layer module 
val domainModule = module {
    factory { GetUserUseCase(get()) }
    factory { SearchProductsUseCase(get()) }
}

// Platform-specific factory
expect class ImageLoaderFactory() {
    fun create(): ImageLoader
}Code language: JavaScript (javascript)

Dependency Injection Done Right

Koin acts as your ingredient supply chain:

// Shared factory for network components
class HttpClientFactory(private val config: NetworkConfig) {
    fun create(): HttpClient {
        return HttpClient {
            install(Logging) {
                level = LogLevel.HEADERS
            }
        }
    }
}

// Koin module setup
val networkModule = module {
    single { NetworkConfig(baseUrl = "https://api.example.com") }
    factory { HttpClientFactory(get()).create() }
}

Modularization That Makes Sense

Structure your project like a well-organized pantry:

shared/
├── core/           # Pure Kotlin utilities
├── features/       # Feature modules
│   └── search/
│       ├── domain/
│       ├── data/
│       └── ui/
├── data/           # Shared repositories
└── platform/       # Platform implementationsCode language: PHP (php)

Testing Layer Boundaries

Verify each station works independently:

class SearchUseCaseTest {
    private val mockRepo = mockk<SearchRepository>()
    private val useCase = SearchUseCase(mockRepo)

    @Test
    fun `empty query returns error`() = runTest {
        every { mockRepo.search("") } returns emptyList()

        val result = useCase.execute("")

        assertTrue(result is SearchResult.Error)
    }
}

@ComposeTest
class SearchScreenTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun showsLoadingState() {
        composeTestRule.setContent {
            SearchScreen(viewModel = FakeSearchViewModel())
        }

        composeTestRule.onNodeWithTag("Loading")
            .assertIsDisplayed()
    }
}Code language: JavaScript (javascript)

Modern Tooling Integration

Network Layer with Ktor:

class KtorApiClient(private val client: HttpClient) {
    suspend fun fetchUser(id: String): User {
        return client.get("https://api.example.com/users/$id")
    }
}

val httpClient = HttpClient(CIO) {
    install(ContentNegotiation) {
        json()
    }
}

Database with SQLDelight:

// Shared schema
CREATE TABLE User (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL
);

// Queries in shared module
val userQueries: UserQueries by inject()

fun getUser(id: String): User? {
    return userQueries.selectById(id).executeAsOneOrNull()
}Code language: JavaScript (javascript)

Platform Factory Pattern

Handle platform differences gracefully:

// Shared contract
expect class FileSystemFactory() {
    fun createTempFile(): File
}

// Android implementation
actual class FileSystemFactory actual constructor() {
    actual fun createTempFile(): File {
        return File.createTempFile("tmp", ".txt")
    }
}

// iOS implementation
actual class FileSystemFactory actual constructor() {
    actual fun createTempFile(): File {
        return NSFileManager.defaultManager
            .createTempFile()!!
    }
}Code language: JavaScript (javascript)

Performance Considerations

Keep your app running smoothly:

  1. Layer Boundaries – Strict input/output contracts
  2. DI Scope – Short-lived dependencies in feature modules
  3. Database Threading – Use Dispatchers.IO for SQL operations
  4. Network Caching – Add Ktor cache plugins
val cacheModule = module {
    single<Cache> { LruCache(maxSize = 10_000) }
}

class CachedApiClient(
    private val delegate: ApiClient,
    private val cache: Cache
) : ApiClient {
    override suspend fun getData(): Data {
        return cache.getOrLoad("data-key") {
            delegate.getData()
        }
    }
}Code language: HTML, XML (xml)

Migration Checklist

Transitioning existing apps to this architecture:

  1. Start with non-UI features
  2. Wrap legacy code in domain interfaces
  3. Gradually extract modules
  4. Add integration tests for critical paths

When Things Go Wrong

Common issues and fixes:

SymptomLikely CauseSolution
Crash on iOSUninitialized KoinUse platform-specific startKoin()
Memory leaksIncorrect scopingApply Koin’s scope functions
Slow renderingHeavy UI layerMove logic to domain layer
Network timeoutsMissing platform configVerify factory implementations

This architecture has powered production apps handling 1M+ users across platforms. It takes some initial setup, but pays dividends in long-term maintainability and team velocity.

0 0 votes
Article Rating

Leave a Reply

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments