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:
Layer | Responsibility | Tools | Testing Approach |
---|---|---|---|
UI | Presentation | Compose | State assertions |
Domain | Business logic | Koin | Pure unit tests |
Data | Networking/DB | Ktor, SQLDelight | Integration tests |
Platform | Device access | Expect/Actual | Mocked 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 implementations
Code 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:
- Layer Boundaries – Strict input/output contracts
- DI Scope – Short-lived dependencies in feature modules
- Database Threading – Use Dispatchers.IO for SQL operations
- 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:
- Start with non-UI features
- Wrap legacy code in domain interfaces
- Gradually extract modules
- Add integration tests for critical paths
When Things Go Wrong
Common issues and fixes:
Symptom | Likely Cause | Solution |
---|---|---|
Crash on iOS | Uninitialized Koin | Use platform-specific startKoin() |
Memory leaks | Incorrect scoping | Apply Koin’s scope functions |
Slow rendering | Heavy UI layer | Move logic to domain layer |
Network timeouts | Missing platform config | Verify 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.