NoteDelight follows Clean Architecture principles combined with MVVM (Model-View-ViewModel) pattern, built entirely with Kotlin Multiplatform for maximum code sharing across platforms.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β APP MODULES β
β (Platform-Specific Entry Points) β
β ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ β
β β Android β β Desktop β β iOS β β Web β β
β ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ β
βββββββββΌβββββββββββββΌβββββββββββββΌβββββββββββββΌββββββββββββββββββ
β β β β
ββββββββββββββ΄βββββββββββββ΄βββββββββββββ
β
ββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββ
β UI LAYER β
β (Compose Multiplatform - 100% Shared) β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Screens, Components, Navigation, Theme β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ
β observes
βββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββ
β PRESENTATION LAYER β
β (ViewModels - MVVM Pattern) β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β State Management, Business Logic Coordination β β
β β StateFlow<State>, Event Handling, Navigation β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ
β uses
βββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββ
β DOMAIN LAYER β
β (Business Logic - Pure Kotlin) β
β ββββββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββ β
β β Use Cases β β Entities β β Repository β β
β β (Operations) β β (Models) β β Interfaces β β
β ββββββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββ
β implements
βββββββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββ
β DATA LAYER β
β (Data Access & Persistence) β
β ββββββββββββββββββββ ββββββββββββββββββββ β
β β SQLDelight β OR β Room β β
β β (Default Impl) β β (Alternative) β β
β ββββββββββββββββββββ ββββββββββββββββββββ β
β DAOs, Database, Repositories, Data Sources β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Each layer has a clear responsibility:
- Domain: Business rules and logic (platform-independent)
- Presentation: UI state management and user interaction
- Data: Data access and persistence
- UI: User interface rendering
Dependencies point inward - outer layers depend on inner layers, never the reverse:
UI β Presentation β Domain β Data
- Domain has zero dependencies (pure Kotlin)
- Presentation depends only on Domain
- Data implements Domain interfaces
- UI depends on Presentation (and Domain for models)
- Domain layer defines interfaces (e.g.,
NoteDAO,DatabaseHolder) - Data layer provides implementations (e.g.,
NoteSQLDelightDAO) - Presentation layer depends on abstractions, not implementations
Location: core:domain
Purpose: Business logic, entities, and abstractions
Key Components:
- Entities (
model/): Business objects (Note, PlatformSQLiteState) - Use Cases (
usecase/): Single-purpose business operations - Repository Interfaces (
db/): Data access contracts - Utilities (
util/): Platform-agnostic helpers
Rules:
- β Pure Kotlin (no platform dependencies)
- β Immutable data classes
- β No framework dependencies
- β No UI code
- β No data implementation details
Example:
// Entity
data class Note(
val id: Long,
val title: String,
val text: String,
val dateCreated: LocalDateTime,
val dateModified: LocalDateTime
)
// Use Case
class CreateNoteUseCase(private val noteDAO: NoteDAO) {
suspend operator fun invoke(title: String, text: String): Long {
// Business logic
val note = Note(...)
noteDAO.insert(note)
return note.id
}
}
// Repository Interface
interface NoteDAO {
val listFlow: Flow<List<Note>>
suspend fun insert(note: Note)
suspend fun delete(note: Note)
}Location: core:presentation
Purpose: Manage UI state and coordinate business logic
Key Components:
- ViewModels (
presentation/): Screen state managers - State Classes (
presentation/*/Result.kt): Data classes for UI state - Action Interfaces (
presentation/*/Result.kt): Sealed interfaces for user actions - Navigation (
navigation/): Navigation abstraction (Router)
Pattern: MVVM (Model-View-ViewModel) with Action-Based Event Handling
Rules:
- β StateFlow for reactive state
- β Action interfaces for event handling
- β ViewModel lifecycle awareness
- β Platform-independent (multiplatform)
- β No direct UI code (Composables)
- β No data implementation details
Action Interface Pattern:
The Action interface pattern reduces callback hell and simplifies @Composable function signatures by centralizing event handling through a single onAction() method:
// State
data class NoteResult(
val loading: Boolean = false,
val note: Note? = null,
val snackBarMessageType: SnackBarMessageType? = null,
) {
enum class SnackBarMessageType { SAVED, EMPTY, DELETED }
fun showLoading(): NoteResult = copy(loading = true)
fun hideLoading(): NoteResult = copy(loading = false)
}
// Action Interface
sealed interface NoteAction {
data class Save(val text: String) : NoteAction
data object Edit : NoteAction
data object Delete : NoteAction
data class CheckSaveChange(val text: String) : NoteAction
}
// ViewModel
class NoteViewModel(...) : ViewModel() {
private val mutableStateFlow = MutableStateFlow(NoteResult())
val stateFlow: StateFlow<NoteResult> = mutableStateFlow
fun onAction(action: NoteAction) = when (action) {
is NoteAction.Save -> saveNote(action.text)
is NoteAction.Edit -> editTitle()
is NoteAction.Delete -> subscribeToDeleteNote()
is NoteAction.CheckSaveChange -> checkSaveChange(action.text)
}
private fun saveNote(text: String) { /* ... */ }
private fun editTitle() { /* ... */ }
// ...
}
// Composable - single onAction parameter instead of multiple callbacks
@Composable
fun NoteDetail(
noteViewModel: NoteViewModel,
) {
val result: NoteResult by noteViewModel.stateFlow.collectAsState()
NoteDetailBody(
result = result,
onAction = noteViewModel::onAction, // Single action handler
)
}
@Composable
fun NoteDetailBody(
result: NoteResult,
onAction: (action: NoteAction) -> Unit = {}, // Simplified signature
) {
// Usage in UI components
IconButton(onClick = { onAction(NoteAction.Save(text)) })
IconButton(onClick = { onAction(NoteAction.Edit) })
IconButton(onClick = { onAction(NoteAction.Delete) })
}Benefits:
- β Reduced callback parameters in Composable functions
- β Type-safe event handling with sealed interfaces
- β Centralized action dispatching
- β Easier testing - single onAction() method to mock
- β Avoids callback hell in complex screens
- β Better state management with explicit action types
When to Use Action Interfaces:
Use Action interfaces when you have 3+ different user actions that need to be passed down through multiple Composable layers. Examples:
- β
NoteAction(Save, Edit, Delete, CheckSaveChange) - 4 actions - β
MainAction(OnNoteClick, OnSettingsClick, OnRefresh) - 3 actions - β
SettingsAction(NavBack, Refresh, ChangeTheme, ChangeLanguage, ChangeEncryption, ChangePassword, ShowCipherVersion, ShowDatabasePath, ShowFileList, RevealFileList) - 10 actions - β
ChangeAction(OnEditOldPassword, OnEditNewPassword, OnEditRepeatPassword, etc.) - 5 actions
When NOT to Use Action Interfaces:
Skip Action interfaces for simple cases with 1-2 actions or functions called directly where ViewModel is obtained:
- β
SignInViewModel.signIn()- Single action, call directly - β
SaveViewModel- 3 methods but simple dialog, not passed down - β
DeleteViewModel- 2 methods but simple dialog, not passed down - β
disposeOneTimeEvents()- Called in same LaunchedEffect where ViewModel is obtained
Rule of Thumb: Use Actions to avoid callback hell in Composables, not to wrap every ViewModel method.
Location: core:data:db-sqldelight or core:data:db-room
Purpose: Data access and persistence
Key Components:
- DAOs: Data access objects implementing domain interfaces
- Database: Database configuration and setup
- Repositories: Repository implementations
- Data Sources: Local/remote data sources
Technologies:
- SQLDelight (default): Type-safe SQL, multiplatform
- Room (alternative): Android-first ORM, experimental KMP support
- SQLCipher: Database encryption (Android, iOS, Desktop JVM)
Rules:
- β Implements domain interfaces
- β Platform-specific implementations (expect/actual)
- β Handles data mapping
- β No business logic
- β No UI concerns
Example:
// DAO Implementation
class NoteSQLDelightDAO(
private val database: NoteDb
) : NoteDAO {
override val listFlow: Flow<List<Note>> =
database.noteQueries.selectAll()
.asFlow()
.mapToList(Dispatchers.IO)
.map { it.map { note -> note.toDomainModel() } }
override suspend fun insert(note: Note) {
database.noteQueries.insert(note.toDbModel())
}
}Location: core:ui
Purpose: User interface rendering and interaction
Key Components:
- Screens (
ui/): Full-screen composables - Components (
ui/component/): Reusable UI components - Theme (
ui/theme/): Material 3 theming - Navigation: Navigation graph implementation
Technology: Compose Multiplatform
Rules:
- β 100% shared across platforms
- β Observes ViewModel state
- β Stateless when possible
- β No business logic
- β No direct data access
Example:
@Composable
fun MainScreen(
viewModel: MainViewModel = koinViewModel()
) {
val state by viewModel.stateFlow.collectAsState()
when (val currentState = state) {
is NoteListResult.Loading -> LoadingIndicator()
is NoteListResult.Success -> NotesList(currentState.notes)
is NoteListResult.Error -> ErrorMessage(currentState.message)
}
}ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β User Action β ViewModel β Use Case β Repository β DB β
β β β
β State β
β β β
β UI ββββββββββββββββββββββββββββββββββββ
β (Re-renders)
- User taps "Create" button (UI Layer)
- UI calls
viewModel.createNote() - ViewModel invokes
CreateNoteUseCase - Use Case validates data and calls
noteDAO.insert() - DAO persists to database
- Database emits updated data via Flow
- ViewModel updates
StateFlowwith new state - UI recomposes automatically (observing StateFlow)
We use Koin for dependency injection:
Benefits:
- Multiplatform support
- Simple Kotlin DSL
- No code generation
- Easy testing
Module Organization:
// Domain module
val domainModule = module {
factory { CreateNoteUseCase(get()) }
factory { SaveNoteUseCase(get()) }
factory { DeleteNoteUseCase(get()) }
}
// Presentation module
val presentationModule = module {
viewModel { MainViewModel(get(), get(), get()) }
viewModel { parameters -> NoteViewModel(get(), parameters.get()) }
}
// Data module
val dataModule = module {
single<NoteDAO> { NoteSQLDelightDAO(get()) }
single { createDatabase(get()) }
}Initialization:
// In app module
startKoin {
modules(domainModule, presentationModule, dataModule)
}ViewModels expose immutable StateFlow<State>:
class ScreenViewModel : ViewModel() {
// Private mutable
private val _stateFlow = MutableStateFlow<State>(State.Initial)
// Public immutable
val stateFlow: StateFlow<State> = _stateFlow
fun action() {
viewModelScope.launch {
_stateFlow.value = State.Loading
try {
val result = performOperation()
_stateFlow.value = State.Success(result)
} catch (e: Exception) {
_stateFlow.value = State.Error(e)
}
}
}
}Type-safe state representation:
sealed class ScreenState {
object Initial : ScreenState()
object Loading : ScreenState()
data class Success(val data: Data) : ScreenState()
data class Error(val message: String) : ScreenState()
}@Composable
fun Screen(viewModel: ScreenViewModel) {
val state by viewModel.stateFlow.collectAsState()
when (state) {
is ScreenState.Initial -> InitialView()
is ScreenState.Loading -> LoadingView()
is ScreenState.Success -> ContentView(state.data)
is ScreenState.Error -> ErrorView(state.message)
}
}Problem: ViewModels shouldn't depend on platform-specific navigation
Solution: Router abstraction
// Interface (in presentation layer)
interface Router {
fun <T : Any> navigate(route: T)
fun <T : Any> navigateClearingBackStack(route: T)
fun popBackStack(): Boolean
suspend fun adaptiveNavigateToDetail(contentKey: Long? = null)
suspend fun adaptiveNavigateBack(): Boolean
}
// Sealed interface for type-safe routes
sealed interface AppNavGraph {
@Serializable
data object Main : AppNavGraph
@Serializable
data class Details(val noteId: Long) : AppNavGraph // managed by adaptive navigation
@Serializable
data object Settings : AppNavGraph
}
// ViewModel uses Router
class MainViewModel(private val router: Router) : ViewModel() {
fun onNoteClicked(id: Long) {
router.navigate(AppNavGraph.Details(id))
}
}
// UI layer implements Router
class ComposeRouter(private val navController: NavController) : Router {
override fun navigate(route: AppNavGraph) {
navController.navigate(route.toRoute())
}
}For platform-specific implementations:
// Common (expect)
expect class CoroutineDispatchers {
val main: CoroutineDispatcher
val io: CoroutineDispatcher
val default: CoroutineDispatcher
}
// Android (actual)
actual class CoroutineDispatchers {
actual val main = Dispatchers.Main
actual val io = Dispatchers.IO
actual val default = Dispatchers.Default
}
// iOS (actual)
actual class CoroutineDispatchers {
actual val main = Dispatchers.Main
actual val io = Dispatchers.Default // No IO dispatcher on iOS
actual val default = Dispatchers.Default
}See TESTING_GUIDE.md for comprehensive testing documentation.
-
100% Shared:
- Domain layer (business logic)
- Presentation layer (ViewModels)
- UI layer (Compose UI)
-
Mostly Shared (with platform specifics):
- Data layer (database drivers differ)
- Utilities (platform-specific implementations)
-
Platform-Specific:
- App entry points
- Platform integration (permissions, etc.)
- Native features
commonMain/ # Shared code (100%)
βββ kotlin/
βββ resources/
androidMain/ # Android-specific
βββ kotlin/
βββ AndroidManifest.xml
iosMain/ # iOS-specific
βββ kotlin/
jvmMain/ # Desktop JVM-specific
βββ kotlin/
wasmJsMain/ # Web-specific
βββ kotlin/
See CONTRIBUTING.md for detailed coding guidelines and best practices.
Use Paging3 library for large datasets:
val pagingDataFlow: Flow<PagingData<Note>> = Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { noteDAO.pagingSource() }
).flow- Use indexes for frequently queried columns
- Limit query results with pagination
- Use transactions for bulk operations
- Cache frequently accessed data
- Use
rememberfor expensive calculations - Provide stable keys in LazyLists
- Avoid recomposition with
derivedStateOf - Use
LazyColumninstead ofColumnfor long lists
- Android: SQLCipher via SafeRoom
- iOS: SQLCipher via CocoaPods
- Desktop: Not implemented yet
- Web: Not supported (browser limitation)
- Never hardcode passwords
- Use platform keystore for sensitive data
- Validate all user input
- Sanitize data before storage
- Follow OWASP mobile security guidelines