Architecture Patterns for KMP + CMP
Overview
Architecture patterns in Kotlin Multiplatform (KMP) with Compose Multiplatform (CMP) should provide a clean, maintainable, and scalable structure that works across all target platforms. This guide covers patterns adapted from Android development but optimized for cross-platform scenarios.
Introduction to KMP + CMP Architecture
Key Concepts and Components
Before diving into specific patterns, it's essential to understand the fundamental building blocks used in KMP + CMP development:
StateFlow and MutableStateFlow
// MutableStateFlow - Writable state container
private val _users = MutableStateFlow<List<User>>(emptyList())
// StateFlow - Read-only state stream
val users: StateFlow<List<User>> = _users.asStateFlow()
StateFlow is a reactive stream that emits values over time. It's the primary way to handle state in Compose Multiplatform:
- MutableStateFlow: Allows writing values (used internally in ViewModels)
- StateFlow: Read-only interface (exposed to UI)
- asStateFlow(): Converts MutableStateFlow to StateFlow for safe external access
ViewModel
class UserListViewModel : ViewModel() {
// ViewModels manage UI state and business logic
// They survive configuration changes and screen rotations
// Perfect for cross-platform state management
}
ViewModel is a lifecycle-aware component that:
- Manages UI-related data and survives configuration changes
- Handles business logic and data operations
- Provides a clean separation between UI and business logic
- Works consistently across all platforms
Compose State Collection
@Composable
fun UserListScreen(viewModel: UserListViewModel) {
// collectAsState() converts StateFlow to Compose state
val users by viewModel.users.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
}
collectAsState() bridges StateFlow with Compose:
- Automatically updates UI when state changes
- Handles lifecycle management
- Provides reactive UI updates
Platform-Specific Implementations
// Shared interface
interface UserRepository {
suspend fun getUsers(): List<User>
}
// Platform-specific implementations
expect class UserRepositoryImpl() : UserRepository
// Android implementation
actual class UserRepositoryImpl : UserRepository {
// Android-specific code
}
// iOS implementation
actual class UserRepositoryImpl : UserRepository {
// iOS-specific code
}
expect/actual mechanism allows:
- Shared interfaces across platforms
- Platform-specific implementations
- Clean separation of concerns
- Code reuse with platform optimization
When to Use Each Pattern
Pattern | Best For | Complexity | Team Size | Maintenance |
---|---|---|---|---|
MVVM | Small to medium apps, quick development | Low | 1-5 developers | Easy |
MVI | Complex state management, predictable flows | Medium | 3-10 developers | Medium |
Clean Architecture | Large apps, strict separation of concerns | High | 5+ developers | High |
Repository Pattern | Data layer abstraction, offline support | Medium | 2+ developers | Medium |
Redux-like | Complex state, time-travel debugging | High | 5+ developers | High |
MVVM (Model-View-ViewModel)
MVVM (Model-View-ViewModel) is the most popular and straightforward architecture pattern for KMP + CMP applications. It provides a clear separation of concerns and is easy to understand and implement.
When to Use MVVM
- Small to medium-sized applications where quick development is priority
- Teams new to KMP who want a familiar pattern
- Applications with simple state management requirements
- Prototypes and MVPs that need rapid iteration
- Cross-platform apps where consistency across platforms is important
Key Benefits
- Simple and familiar - Easy to understand for developers coming from Android/iOS
- Good separation of concerns - Clear boundaries between UI, business logic, and data
- Testable - ViewModels can be easily unit tested
- Platform-agnostic - Works consistently across all target platforms
- Reactive UI updates - Automatic UI updates when state changes
Basic MVVM Structure
// Shared Model
data class User(
val id: String,
val name: String,
val email: String
)
// Shared Repository
interface UserRepository {
suspend fun getUsers(): List<User>
suspend fun getUser(id: String): User?
suspend fun saveUser(user: User)
}
// Platform-specific Repository Implementation
expect class UserRepositoryImpl() : UserRepository
// Shared ViewModel
class UserListViewModel(
private val userRepository: UserRepository
) : ViewModel() {
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
init {
loadUsers()
}
fun loadUsers() {
viewModelScope.launch {
try {
_isLoading.value = true
_error.value = null
_users.value = userRepository.getUsers()
} catch (e: Exception) {
_error.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun refreshUsers() {
loadUsers()
}
}
// Compose UI
@Composable
fun UserListScreen(
viewModel: UserListViewModel = viewModel()
) {
val users by viewModel.users.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
Column(
modifier = Modifier.fillMaxSize()
) {
if (isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (error != null) {
ErrorView(
message = error!!,
onRetry = { viewModel.refreshUsers() }
)
} else {
LazyColumn {
items(users) { user ->
UserItem(user = user)
}
}
}
}
}
Platform-Specific Implementations
This section demonstrates how to implement platform-specific logic while maintaining a shared interface. This is crucial for KMP development where you need to handle platform differences gracefully.
Key Points:
- expect/actual mechanism allows shared interfaces with platform-specific implementations
- Platform detection helps adapt behavior for different platforms
- Dependency injection can be used to provide platform-specific services
- Error handling may need to be platform-specific due to different APIs
// Android Implementation
actual class UserRepositoryImpl : UserRepository {
override suspend fun getUsers(): List<User> {
// Android-specific implementation
return withContext(Dispatchers.IO) {
// Database or API call
}
}
override suspend fun getUser(id: String): User? {
// Android-specific implementation
}
override suspend fun saveUser(user: User) {
// Android-specific implementation
}
}
// iOS Implementation
actual class UserRepositoryImpl : UserRepository {
override suspend fun getUsers(): List<User> {
// iOS-specific implementation
return withContext(Dispatchers.Default) {
// Core Data or API call
}
}
override suspend fun getUser(id: String): User? {
// iOS-specific implementation
}
override suspend fun saveUser(user: User) {
// iOS-specific implementation
}
}
MVI (Model-View-Intent)
MVI (Model-View-Intent) is a unidirectional data flow architecture that provides predictable state management and excellent debugging capabilities. It's particularly useful for complex applications with intricate state requirements.
When to Use MVI
- Complex state management where you need predictable data flow
- Applications with many user interactions that affect multiple parts of the UI
- Teams that need excellent debugging and state inspection capabilities
- Applications requiring time-travel debugging or state replay
- Large teams where consistency in state management is crucial
Key Benefits
- Unidirectional data flow - All state changes flow in one direction
- Predictable state - Easy to understand how state changes occur
- Excellent debugging - Can inspect and replay state changes
- Testable - Each component can be tested in isolation
- Scalable - Works well for large applications with complex state
MVI Architecture Implementation
// Intent (User Actions)
sealed class UserIntent {
object LoadUsers : UserIntent()
object RefreshUsers : UserIntent()
data class SelectUser(val user: User) : UserIntent()
object ClearError : UserIntent()
}
// State (UI State)
data class UserState(
val users: List<User> = emptyList(),
val selectedUser: User? = null,
val isLoading: Boolean = false,
val error: String? = null
)
// Effect (One-time events)
sealed class UserEffect {
data class NavigateToUserDetail(val userId: String) : UserEffect()
data class ShowToast(val message: String) : UserEffect()
}
// Store/ViewModel
class UserStore(
private val userRepository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow(UserState())
val state: StateFlow<UserState> = _state.asStateFlow()
private val _effect = Channel<UserEffect>()
val effect = _effect.receiveAsFlow()
fun dispatch(intent: UserIntent) {
when (intent) {
is UserIntent.LoadUsers -> loadUsers()
is UserIntent.RefreshUsers -> refreshUsers()
is UserIntent.SelectUser -> selectUser(intent.user)
is UserIntent.ClearError -> clearError()
}
}
private fun loadUsers() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val users = userRepository.getUsers()
_state.value = _state.value.copy(
users = users,
isLoading = false
)
} catch (e: Exception) {
_state.value = _state.value.copy(
error = e.message,
isLoading = false
)
}
}
}
private fun refreshUsers() {
loadUsers()
}
private fun selectUser(user: User) {
_state.value = _state.value.copy(selectedUser = user)
viewModelScope.launch {
_effect.send(UserEffect.NavigateToUserDetail(user.id))
}
}
private fun clearError() {
_state.value = _state.value.copy(error = null)
}
}
// Compose UI
@Composable
fun UserListScreen(
store: UserStore = viewModel()
) {
val state by store.state.collectAsState()
val effect by store.effect.collectAsState(initial = null)
LaunchedEffect(effect) {
effect?.let { userEffect ->
when (userEffect) {
is UserEffect.NavigateToUserDetail -> {
// Handle navigation
}
is UserEffect.ShowToast -> {
// Show toast message
}
}
}
}
Column(
modifier = Modifier.fillMaxSize()
) {
if (state.isLoading) {
LoadingView()
} else if (state.error != null) {
ErrorView(
message = state.error!!,
onRetry = { store.dispatch(UserIntent.RefreshUsers) }
)
} else {
LazyColumn {
items(state.users) { user ->
UserItem(
user = user,
isSelected = user == state.selectedUser,
onClick = { store.dispatch(UserIntent.SelectUser(user)) }
)
}
}
}
}
}
Clean Architecture
Clean Architecture provides a strict separation of concerns with clear boundaries between different layers of your application. It's ideal for large, complex applications that need to be maintainable and testable over time.
When to Use Clean Architecture
- Large enterprise applications with complex business logic
- Applications with strict separation requirements between layers
- Teams working on long-term projects that need maintainability
- Applications with complex domain logic that needs to be isolated
- Projects requiring high test coverage and dependency inversion
Key Benefits
- Dependency inversion - High-level modules don't depend on low-level modules
- Testability - Each layer can be tested independently
- Maintainability - Clear boundaries make changes easier to implement
- Scalability - Easy to add new features without affecting existing code
- Platform independence - Business logic is completely platform-agnostic
Domain Layer
// Use Cases
class GetUsersUseCase(
private val userRepository: UserRepository
) {
suspend operator fun invoke(): List<User> {
return userRepository.getUsers()
}
}
class GetUserUseCase(
private val userRepository: UserRepository
) {
suspend operator fun invoke(id: String): User? {
return userRepository.getUser(id)
}
}
class SaveUserUseCase(
private val userRepository: UserRepository
) {
suspend operator fun invoke(user: User) {
userRepository.saveUser(user)
}
}
// Domain Models
data class User(
val id: String,
val name: String,
val email: String
)
// Repository Interface
interface UserRepository {
suspend fun getUsers(): List<User>
suspend fun getUser(id: String): User?
suspend fun saveUser(user: User)
}
Data Layer
// Data Models
data class UserDto(
val id: String,
val name: String,
val email: String
) {
fun toDomain(): User = User(id, name, email)
}
data class User(
val id: String,
val name: String,
val email: String
) {
fun toDto(): UserDto = UserDto(id, name, email)
}
// Repository Implementation
class UserRepositoryImpl(
private val userApi: UserApi,
private val userDatabase: UserDatabase
) : UserRepository {
override suspend fun getUsers(): List<User> {
return try {
// Try to get from API first
val apiUsers = userApi.getUsers()
val domainUsers = apiUsers.map { it.toDomain() }
// Cache in database
userDatabase.saveUsers(apiUsers)
domainUsers
} catch (e: Exception) {
// Fallback to database
userDatabase.getUsers().map { it.toDomain() }
}
}
override suspend fun getUser(id: String): User? {
return userDatabase.getUser(id)?.toDomain()
}
override suspend fun saveUser(user: User) {
userDatabase.saveUser(user.toDto())
}
}
Presentation Layer
// ViewModel with Use Cases
class UserListViewModel(
private val getUsersUseCase: GetUsersUseCase,
private val saveUserUseCase: SaveUserUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(UserListUiState())
val uiState: StateFlow<UserListUiState> = _uiState.asStateFlow()
init {
loadUsers()
}
fun loadUsers() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
try {
val users = getUsersUseCase()
_uiState.value = _uiState.value.copy(
users = users,
isLoading = false
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = e.message,
isLoading = false
)
}
}
}
}
// UI State
data class UserListUiState(
val users: List<User> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
Repository Pattern
Repository Pattern abstracts data access logic and provides a clean interface for data operations. It's particularly useful for applications that need to work with multiple data sources or require offline capabilities.
When to Use Repository Pattern
- Applications with multiple data sources (API, local database, cache)
- Offline-first applications that need to work without internet
- Applications requiring data synchronization between local and remote sources
- Complex data operations that need to be abstracted from business logic
- Applications with caching requirements for performance optimization
Key Benefits
- Data source abstraction - Business logic doesn't know about data sources
- Offline support - Can work with local data when offline
- Caching strategies - Implement smart caching for better performance
- Testability - Easy to mock data sources for testing
- Platform flexibility - Different platforms can implement data sources differently
Generic Repository
// Generic Repository Interface
interface Repository<T, ID> {
suspend fun getAll(): List<T>
suspend fun getById(id: ID): T?
suspend fun save(item: T)
suspend fun delete(id: ID)
suspend fun update(item: T)
}
// Generic Repository Implementation
abstract class BaseRepository<T, ID>(
private val api: Api<T>,
private val database: Database<T, ID>
) : Repository<T, ID> {
override suspend fun getAll(): List<T> {
return try {
val items = api.getAll()
database.saveAll(items)
items
} catch (e: Exception) {
database.getAll()
}
}
override suspend fun getById(id: ID): T? {
return database.getById(id)
}
override suspend fun save(item: T) {
database.save(item)
try {
api.save(item)
} catch (e: Exception) {
// Handle offline scenario
}
}
override suspend fun delete(id: ID) {
database.delete(id)
try {
api.delete(id)
} catch (e: Exception) {
// Handle offline scenario
}
}
override suspend fun update(item: T) {
database.update(item)
try {
api.update(item)
} catch (e: Exception) {
// Handle offline scenario
}
}
}
// Specific Repository
class UserRepositoryImpl(
userApi: UserApi,
userDatabase: UserDatabase
) : BaseRepository<User, String>(userApi, userDatabase)
Dependency Injection
Dependency Injection (DI) is a design pattern that provides a way to inject dependencies into classes rather than having them create dependencies themselves. In KMP, Koin is the most popular DI framework due to its simplicity and Kotlin-first approach.
When to Use Dependency Injection
- Applications with complex dependency graphs that need to be managed
- Teams that want to improve testability by easily swapping implementations
- Applications requiring different implementations for different platforms
- Large applications where manual dependency management becomes unwieldy
- Applications with singleton requirements that need to be shared across components
Key Benefits
- Testability - Easy to inject mock dependencies for testing
- Loose coupling - Classes don't need to know how to create their dependencies
- Platform flexibility - Different platforms can provide different implementations
- Lifecycle management - Automatic management of object lifecycles
- Configuration management - Centralized configuration of dependencies
Koin Setup
// Shared Module
val sharedModule = module {
// Repositories
single<UserRepository> { UserRepositoryImpl(get(), get()) }
single<PostRepository> { PostRepositoryImpl(get(), get()) }
// Use Cases
factory { GetUsersUseCase(get()) }
factory { GetUserUseCase(get()) }
factory { SaveUserUseCase(get()) }
// ViewModels
factory { UserListViewModel(get(), get()) }
factory { UserDetailViewModel(get(), get()) }
}
// Platform-specific modules
expect val platformModule: Module
// Android Module
actual val platformModule = module {
// Android-specific dependencies
single<UserApi> { UserApiImpl() }
single<UserDatabase> { UserDatabaseImpl(get()) }
}
// iOS Module
actual val platformModule = module {
// iOS-specific dependencies
single<UserApi> { UserApiImpl() }
single<UserDatabase> { UserDatabaseImpl() }
}
Navigation Architecture
Navigation Architecture in KMP + CMP provides a way to handle navigation between different screens while maintaining platform-specific navigation patterns. Compose Navigation is the standard solution for handling navigation in Compose Multiplatform applications.
When to Use Compose Navigation
- Applications with multiple screens that need navigation between them
- Applications requiring deep linking support
- Applications with complex navigation flows (nested navigation, bottom tabs)
- Applications needing platform-specific navigation behavior
- Applications requiring navigation state management and restoration
Key Benefits
- Type-safe navigation - Compile-time checking of navigation routes
- Platform consistency - Works the same way across all platforms
- Deep linking support - Easy to implement deep linking
- State restoration - Navigation state is preserved across app restarts
- Testing support - Easy to test navigation flows
Compose Navigation
// Navigation Setup
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "userList"
) {
composable("userList") {
UserListScreen(
onUserClick = { userId ->
navController.navigate("userDetail/$userId")
}
)
}
composable(
"userDetail/{userId}",
arguments = listOf(navArgument("userId") { type = NavType.StringType })
) { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")
UserDetailScreen(userId = userId)
}
}
}
// Screen with Navigation
@Composable
fun UserListScreen(
onUserClick: (String) -> Unit
) {
val viewModel: UserListViewModel = viewModel()
val users by viewModel.users.collectAsState()
LazyColumn {
items(users) { user ->
UserItem(
user = user,
onClick = { onUserClick(user.id) }
)
}
}
}
State Management Patterns
State Management Patterns provide different approaches to managing application state in a predictable and maintainable way. These patterns help organize how data flows through your application and how UI components react to state changes.
When to Use State Management Patterns
- Applications with complex state that affects multiple UI components
- Applications requiring predictable state changes and debugging
- Applications with shared state that needs to be accessed by multiple screens
- Applications needing state persistence across app sessions
- Applications requiring state synchronization between different parts of the app
Key Benefits
- Predictable state changes - Clear flow of how state is updated
- Centralized state management - Single source of truth for application state
- Debugging capabilities - Easy to track state changes and debug issues
- Testability - State logic can be tested independently
- Scalability - Easy to add new state requirements as the app grows
State Container Pattern
// State Container
class UserStateContainer(
private val userRepository: UserRepository
) {
private val _state = MutableStateFlow(UserState())
val state: StateFlow<UserState> = _state.asStateFlow()
fun dispatch(action: UserAction) {
when (action) {
is UserAction.LoadUsers -> loadUsers()
is UserAction.SelectUser -> selectUser(action.user)
is UserAction.Refresh -> refresh()
}
}
private fun loadUsers() {
CoroutineScope(Dispatchers.Main).launch {
_state.value = _state.value.copy(isLoading = true)
try {
val users = userRepository.getUsers()
_state.value = _state.value.copy(
users = users,
isLoading = false
)
} catch (e: Exception) {
_state.value = _state.value.copy(
error = e.message,
isLoading = false
)
}
}
}
private fun selectUser(user: User) {
_state.value = _state.value.copy(selectedUser = user)
}
private fun refresh() {
loadUsers()
}
}
// Actions
sealed class UserAction {
object LoadUsers : UserAction()
data class SelectUser(val user: User) : UserAction()
object Refresh : UserAction()
}
// State
data class UserState(
val users: List<User> = emptyList(),
val selectedUser: User? = null,
val isLoading: Boolean = false,
val error: String? = null
)
// Usage in Compose
@Composable
fun UserListScreen(
stateContainer: UserStateContainer = remember { UserStateContainer(get()) }
) {
val state by stateContainer.state.collectAsState()
Column {
if (state.isLoading) {
CircularProgressIndicator()
} else {
LazyColumn {
items(state.users) { user ->
UserItem(
user = user,
onClick = { stateContainer.dispatch(UserAction.SelectUser(user)) }
)
}
}
}
}
}
Best Practices
Best Practices for KMP + CMP architecture help ensure your code is maintainable, testable, and follows industry standards. These practices are derived from real-world experience and help avoid common pitfalls in cross-platform development.
Key Principles
- Platform abstraction - Keep platform-specific code isolated
- Error handling - Implement robust error handling strategies
- Testing - Write comprehensive tests for all architecture layers
- Performance - Consider performance implications of architectural decisions
- Maintainability - Design for long-term maintainability
1. Platform Abstraction
// Abstract platform-specific functionality
expect class PlatformStorage() {
suspend fun save(key: String, value: String)
suspend fun get(key: String): String?
}
// Platform implementations
actual class PlatformStorage {
actual suspend fun save(key: String, value: String) {
// Platform-specific implementation
}
actual suspend fun get(key: String): String? {
// Platform-specific implementation
}
}
2. Error Handling
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
object Loading : Result<Nothing>()
}
class SafeRepository<T>(
private val repository: Repository<T, String>
) {
suspend fun getData(): Result<T> {
return try {
Result.Success(repository.getById("id"))
} catch (e: Exception) {
Result.Error(e)
}
}
}
3. Testing Architecture
@Test
fun testUserListViewModel() {
val mockRepository = mockk<UserRepository>()
val viewModel = UserListViewModel(mockRepository)
coEvery { mockRepository.getUsers() } returns listOf(User("1", "John", "john@example.com"))
viewModel.loadUsers()
assertEquals(1, viewModel.users.value.size)
assertEquals("John", viewModel.users.value[0].name)
}
This comprehensive guide covers the most important architecture patterns for KMP + CMP development, providing a solid foundation for building scalable cross-platform applications.