Greenhouse Admin follows a clean architecture approach using the MVVM (Model-View-ViewModel) pattern combined with the Repository pattern to ensure separation of concerns, testability, and maintainability across all platforms.
Architecture layers
The application is structured in three distinct layers:
Presentation layer
Contains UI components and ViewModels that manage UI state and handle user interactions.
presentation/
├── ui/
│ ├── components/ # Reusable Compose components
│ ├── screens/ # Full screen composables
│ └── theme/ # Colors, Typography, Theme
└── viewmodel/ # ViewModels for state management
ViewModels use StateFlow to expose immutable state to the UI and handle all business logic coordination:
presentation/viewmodel/LoginViewModel.kt
class LoginViewModel (
private val authRepository: AuthRepository
) : ViewModel () {
private val _uiState = MutableStateFlow ( LoginUiState ())
val uiState: StateFlow < LoginUiState > = _uiState. asStateFlow ()
fun onEvent (event: LoginEvent ) {
when (event) {
is LoginEvent.OnLoginClicked -> performLogin ()
// ... other events
}
}
}
Domain layer
Defines business logic interfaces and use cases (future). Currently contains repository interfaces:
domain/
├── repository/ # Repository interfaces
└── auth/ # Authentication event management
Repository interfaces define contracts for data operations:
domain/repository/AuthRepository.kt
interface AuthRepository {
suspend fun login (username: String , password: String ): Result < UserSession >
suspend fun logout (): Result < Unit >
fun isAuthenticated (): Boolean
fun getCurrentSession (): UserSession ?
suspend fun validateSession (): Boolean
}
Data layer
Implements repository interfaces and handles all data operations:
data/
├── model/ # DTOs and domain models
├── remote/
│ ├── api/ # API service implementations
│ └── KtorClient.kt # HTTP client configuration
├── local/ # Local storage (expect/actual)
└── repository/ # Repository implementations
Repository implementations coordinate between API services and local storage:
data/repository/AuthRepositoryImpl.kt
class AuthRepositoryImpl (
private val authApiService: AuthApiService ,
private val tokenStorage: TokenStorage
) : AuthRepository {
override suspend fun login (username: String , password: String ): Result < UserSession > {
return runCatching {
val response = authApiService. login (username, password)
tokenStorage. saveAccessToken (response.token)
tokenStorage. saveRoles (response.roles)
response. toUserSession ()
}
}
}
Dependency injection with Koin
Greenhouse Admin uses Koin 4.1.1 for dependency injection across all platforms. Dependencies are organized into modules:
Module organization
DataModule - Data layer dependencies
Provides HTTP clients, API services, and repository implementations: val dataModule = module {
// Platform-specific token storage
singleOf (:: TokenStorage )
// HTTP Client with auth interceptor
single { createHttpClient ( get (), get ()) }
// API Services
singleOf (:: AuthApiService )
singleOf (:: TenantsApiService )
singleOf (:: GreenhousesApiService )
// ... more services
// Repository implementations bound to interfaces
singleOf (:: AuthRepositoryImpl ) bind AuthRepository:: class
singleOf (:: ClientsRepositoryImpl ) bind ClientsRepository:: class
// ... more repositories
}
PresentationModule - ViewModels
Provides ViewModels for the MVVM architecture: val presentationModule = module {
viewModelOf (:: LoginViewModel )
viewModelOf (:: DashboardViewModel )
viewModelOf (:: ClientsViewModel )
// ViewModel with parameters
viewModel { (clientId: Long ) ->
ClientDetailViewModel (clientId, get (), get (), get ())
}
}
PlatformModule - Platform-specific dependencies
Koin initialization
Koin must be initialized before accessing any dependencies:
fun initKoin (appDeclaration: KoinAppDeclaration ? = null ) {
startKoin {
appDeclaration?. invoke ( this )
modules (
dataModule,
domainModule,
presentationModule,
platformModule ()
)
}
}
Each platform initializes Koin at its entry point:
// GreenhouseApplication.kt
class GreenhouseApplication : Application () {
override fun onCreate () {
super . onCreate ()
initKoin { androidContext ( this@GreenhouseApplication ) }
}
}
Using ViewModels in Composables
Inject ViewModels using Koin’s Compose integration:
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun LoginScreen () {
val viewModel: LoginViewModel = koinViewModel ()
val uiState by viewModel.uiState. collectAsState ()
// UI implementation
}
Repository pattern
The repository pattern provides a clean API for data access and abstracts the data source:
Define interface in domain layer
Repository interfaces live in domain/repository/ and define what operations are available: interface ClientsRepository {
suspend fun getAllClients (): Result < List < Client >>
suspend fun getClientById (id: Long ): Result < Client >
suspend fun createClient (request: CreateTenantRequest ): Result < Client >
}
Implement in data layer
Repository implementations live in data/repository/ and coordinate data sources: class ClientsRepositoryImpl (
private val tenantsApiService: TenantsApiService
) : ClientsRepository {
override suspend fun getAllClients (): Result < List < Client >> {
return runCatching {
tenantsApiService. getAllTenants (). map { it. toClient () }
}
}
}
Bind in Koin module
Register the implementation and bind it to the interface: singleOf (:: ClientsRepositoryImpl ) bind ClientsRepository:: class
Inject in ViewModel
ViewModels depend on repository interfaces, not implementations: class ClientsViewModel (
private val clientsRepository: ClientsRepository
) : ViewModel () {
// ViewModel logic
}
The repository pattern enables easy testing by allowing mock implementations to be injected during tests.
Complete directory structure
Here’s the full structure of the application in composeApp/src/commonMain/kotlin/com/apptolast/greenhouse/admin/:
├── data/
│ ├── local/ # Platform-specific storage
│ ├── model/ # DTOs and domain models
│ ├── remote/
│ │ ├── api/ # API service implementations
│ │ └── KtorClient.kt # HTTP client setup
│ └── repository/ # Repository implementations
├── di/
│ ├── DataModule.kt
│ ├── DomainModule.kt
│ ├── PresentationModule.kt
│ ├── PlatformModule.kt # expect declaration
│ └── KoinInitializer.kt
├── domain/
│ ├── auth/ # Auth event management
│ └── repository/ # Repository interfaces
└── presentation/
├── navigation/ # Navigation logic
├── ui/
│ ├── adaptive/ # Responsive UI helpers
│ ├── components/ # Reusable components
│ ├── screens/ # Screen composables
│ └── theme/ # App theme
└── viewmodel/ # ViewModels
Best practices
Constructor injection Always use constructor injection with Koin, never field injection.
Depend on interfaces ViewModels and use cases should depend on repository interfaces, not concrete implementations.
StateFlow for UI state Use StateFlow in ViewModels and collect with collectAsState() in Composables.
Result wrapper Repository methods return Result<T> for consistent error handling.
Multiplatform structure Learn about source sets and platform-specific code
Authentication flow Understand how auth works in the architecture