Skip to main content
Greenhouse Admin is built with Kotlin Multiplatform (KMP) to maximize code sharing across all platforms while maintaining the flexibility to use platform-specific APIs when needed.

Source set structure

The project uses a hierarchy of source sets to organize shared and platform-specific code:
composeApp/src/
├── commonMain/          # Code shared across ALL platforms
├── androidMain/         # Android-specific implementations
├── iosMain/             # iOS-specific implementations
├── jvmMain/             # Desktop (JVM) specific
├── jsMain/              # JavaScript target
├── wasmJsMain/          # WebAssembly target (primary web)
└── webMain/             # Shared between JS and WasmJS
The primary deployment target is Web using WasmJS, with mobile platforms planned for future expansion.

Common main (shared code)

The commonMain source set contains all code that’s platform-agnostic:
  • UI components built with Compose Multiplatform
  • Business logic in ViewModels
  • Repository interfaces and implementations
  • Domain models and DTOs
  • API services using Ktor client
  • DI modules with Koin
commonMain example
// Works identically on all platforms
class LoginViewModel(
    private val authRepository: AuthRepository
) : ViewModel() {
    // Platform-agnostic state management
    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
}

Platform-specific source sets

Each platform has its own source set for platform-specific implementations:
androidMain contains Android-specific code:
androidMain/kotlin/com/apptolast/greenhouse/admin/
├── GreenhouseApplication.kt    # Application class
├── MainActivity.kt              # Main activity
├── data/
│   └── local/
│       └── TokenStorage.android.kt  # Android token storage
└── di/
    └── PlatformModule.android.kt    # Android DI
Example Android-specific implementation:
TokenStorage.android.kt
actual class TokenStorage actual constructor() {
    // Android uses in-memory storage for now
    // Production should use EncryptedSharedPreferences
    private var accessToken: String? = null
    
    actual fun getAccessToken(): String? = accessToken
    actual fun saveAccessToken(token: String) {
        accessToken = token
    }
}

Expect/actual pattern

The expect/actual mechanism allows you to declare platform-specific APIs in common code and provide platform-specific implementations.

When to use expect/actual

Use expect/actual when:
  • No multiplatform library exists for the functionality
  • You need to access platform-native APIs
  • Different platforms require fundamentally different implementations
  • Creating factory functions for platform-specific types
Avoid expect/actual when:
  • A multiplatform library already exists (use kotlinx-datetime instead of platform dates)
  • An interface with dependency injection would suffice
  • The logic can be abstracted into common code

Expect/actual example: TokenStorage

1

Declare expect in commonMain

Define the contract that all platforms must implement:
commonMain/data/local/TokenStorage.kt
/**
 * Platform-specific token storage for JWT tokens.
 */
expect class TokenStorage() {
    fun getAccessToken(): String?
    fun saveAccessToken(token: String)
    fun getTokenType(): String?
    fun saveTokenType(type: String)
    fun getUsername(): String?
    fun saveUsername(username: String)
    fun getRoles(): List<String>
    fun saveRoles(roles: List<String>)
    fun clearTokens()
    fun isAuthenticated(): Boolean
}
2

Implement actual in each platform

Each platform provides its own implementation:
androidMain/data/local/TokenStorage.android.kt
actual class TokenStorage actual constructor() {
    private var accessToken: String? = null
    private var tokenType: String? = null
    private var username: String? = null
    private var roles: List<String> = emptyList()
    
    actual fun getAccessToken(): String? = accessToken
    actual fun saveAccessToken(token: String) {
        accessToken = token
    }
    // ... other methods
}
iosMain/data/local/TokenStorage.ios.kt
import platform.Foundation.NSUserDefaults

actual class TokenStorage actual constructor() {
    private val defaults = NSUserDefaults.standardUserDefaults
    
    actual fun getAccessToken(): String? = 
        defaults.stringForKey(KEY_ACCESS_TOKEN)
        
    actual fun saveAccessToken(token: String) {
        defaults.setObject(token, KEY_ACCESS_TOKEN)
    }
    
    companion object {
        private const val KEY_ACCESS_TOKEN = "greenhouse_access_token"
    }
}
wasmJsMain/data/local/TokenStorage.wasmJs.kt
import kotlinx.browser.localStorage

actual class TokenStorage actual constructor() {
    actual fun getAccessToken(): String? = 
        localStorage.getItem(KEY_ACCESS_TOKEN)
        
    actual fun saveAccessToken(token: String) {
        localStorage.setItem(KEY_ACCESS_TOKEN, token)
    }
    
    actual fun clearTokens() {
        localStorage.removeItem(KEY_ACCESS_TOKEN)
        localStorage.removeItem(KEY_TOKEN_TYPE)
        localStorage.removeItem(KEY_USERNAME)
        localStorage.removeItem(KEY_ROLES)
    }
    
    companion object {
        private const val KEY_ACCESS_TOKEN = "greenhouse_access_token"
        private const val KEY_TOKEN_TYPE = "greenhouse_token_type"
        private const val KEY_USERNAME = "greenhouse_username"
        private const val KEY_ROLES = "greenhouse_roles"
    }
}
3

Use in common code

The common code can now use TokenStorage without knowing about platform details:
commonMain
class AuthRepositoryImpl(
    private val authApiService: AuthApiService,
    private val tokenStorage: TokenStorage  // Works on all platforms
) : AuthRepository {
    override suspend fun login(username: String, password: String): Result<UserSession> {
        return runCatching {
            val response = authApiService.login(username, password)
            tokenStorage.saveAccessToken(response.token)  // Platform-specific
            response.toUserSession()
        }
    }
}

Platform modules pattern

Platform-specific Koin modules use the expect/actual pattern:
commonMain/di/PlatformModule.kt
// Declare that each platform will provide a module
expect fun platformModule(): Module
androidMain/di/PlatformModule.android.kt
import org.koin.dsl.module

actual fun platformModule() = module {
    // Android-specific dependencies
}
wasmJsMain/di/PlatformModule.wasmJs.kt
import org.koin.dsl.module

actual fun platformModule() = module {
    // WasmJS-specific dependencies
}

Platform-specific dependencies

Different platforms require different Ktor engines:
[libraries]
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
These are automatically selected based on the target platform in the build configuration.

Building for different platforms

# WebAssembly (recommended)
./gradlew :composeApp:wasmJsBrowserDevelopmentRun

# JavaScript (legacy)
./gradlew :composeApp:jsBrowserDevelopmentRun

# Production build
./gradlew :composeApp:wasmJsBrowserDistribution
After adding dependencies, update Yarn lock files:
./gradlew kotlinWasmUpgradeYarnLock  # For WasmJS
./gradlew kotlinUpgradeYarnLock       # For JS

Code sharing statistics

Greenhouse Admin achieves high code reuse across platforms:

~95% UI code shared

All Compose UI components work identically on all platforms

100% business logic shared

ViewModels, repositories, and domain logic are fully shared

100% network layer shared

Ktor client and API services work everywhere

~10% platform-specific

Only storage, navigation, and entry points differ

Best practices

1

Maximize common code

Write as much code as possible in commonMain. Only use platform-specific code when absolutely necessary.
2

Prefer multiplatform libraries

Use libraries like kotlinx-datetime, kotlinx-coroutines, and kotlinx-serialization instead of platform-specific alternatives.
3

Keep platform code minimal

Platform-specific implementations should be thin wrappers around native APIs.
4

Test on all targets

Regularly build and test on all supported platforms to catch platform-specific issues early.

Architecture

Learn about the MVVM architecture and dependency injection

Authentication

See how auth works across all platforms

Build docs developers (and LLMs) love