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 │ │
│ │ (Selectable) │ │ (Selectable) │ │
│ └──────────────────┘ └──────────────────┘ │
│ 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: Type-safe SQL on Android, Desktop, iOS, and WasmJS
- Room 3: Annotation-based ORM on Android, Desktop, iOS, and WasmJS
- SQLCipher / SQLite3MultipleCiphers: Compatible encryption on native/JVM and Web
CORE_DATA_DB_MODULE is the single selector. settings.gradle.kts validates it and includes
only the selected module. Both implementations preserve notes.db, the v1 note table, epoch
millisecond dates, and the domain contracts, so switching requires only exchanging the two
comments in gradle.properties.
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 Kotlin SwiftPM import
- Desktop: SQLCipher through
sqlite-jdbc-crypt - Web: SQLite3MultipleCiphers through an OPFS Web Worker
- Never hardcode passwords
- Use platform keystore for sensitive data
- Validate all user input
- Sanitize data before storage
- Follow OWASP mobile security guidelines