This is a ready-to-go Offline First Compose Multiplatform starter kit built to help you spin up full-stack apps fast. It bundles a Compose Multiplatform frontend for Android, iOS, Desktop, and Web right alongside a built-in backend. Everything is pre-wired so you can skip the boilerplate and go straight to shipping for every platform from a single codebase.
- Architecture
- Development Workflow
- Offline Engine
- FAQ
- How do I set up the project from scratch?
- Why is dependency injection done manually instead of using a DI framework?
- Should I share domain models directly with the server, or use separate DTOs?
- How do I add a platform-specific implementation?
- Can I drop down to native platform code if KMP lacks a feature?
- How do I handle platform-specific permissions?
- How do I add offline support for a new operation?
- How are data sync conflicts handled between the client and server?
- Can I use the client without the bundled server?
- How do I bump the app version for a release?
- Do you use AI?
Kotlin Multiplatform project following a Layered Clean Architecture designed around a Unidirectional Dependency Flow.
- Android
- iOS
- Windows
- macOS
- Linux
- Web
| Source Set | Platform |
|---|---|
androidMain |
Android API 26+ |
appleMain |
iOS 16+ / macOS |
desktopMain |
JVM 21 |
webMain |
WASM-JS |
server |
JVM 21 |
- UI: Compose Multiplatform
- Client Database: SQLDelight
- Server Database Exposed
- Networking: Ktor
- Code Coverage: Kover
shared: Code that is reused across all target platforms (Client and Server).design: Design System where all UI components live.composeApp: Client side for Android, iOS, Desktop, and Web.server: The backend for the application.
The packages across these modules obey the same structure.
Each package has a specific isolated responsibility, and dependencies always point in a single direction down the stack.
- core: Business logic agnostic implementations. It is the foundation and cannot depend on any other layer.
- data: Responsible for data persistence and retrieval. Provides infrastructure for Network, Database and Storage. It can only depend on core.
- domain: Business workflows and rules. It can depend on data and core.
- ui: User-facing presentation layer. It can depend on domain, data and core.
- test: Test utilities like annotations and fake data shared across test source sets. It can depend on all layers, domain, data, core, ui.
- config: Client remote configs.
ClientFlagsandClientConfigsare containers for feature flags and configurations respectively. Located on the shared module since they are used by the client but changed by the server. - flow: Coroutine dispatchers.
Dispatcher: ProvidesMain,DefaultandIOcoroutine dispatchers as mutable properties so tests can substitute their own implementations.
- locale: Localization and date/time formatting.
DateTime: Utilities for time, e.g. getting the current time in UTC/ISO8601.
- platform: OS-specific APIs.
Platform: Provides a snapshot of the current execution environment with a sealed interface that defines the possible platforms that the application can run on.
- security: Cryptography and UUID utilities.
Uuid: UUID generation via the best available platform algorithm.
- telemetry: Logging and crash reporting.
Telemetry: Hub for dispatching telemetry data, including logs, error reports, and user feedback, by iterating over pluggableTelemetryEngineimplementations.
- http:
Ktorconfiguration and network logic.Header: Sealed class of all header keys.Query: Sealed class of all query parameter keys.URL: Sealed class of all remote endpoints, serving as a centralized registry for API routing.
- serializer: JSON parsing and serialization.
Json: Helpers for encoding/decoding.
Contains all the domain models.
Annotations used to exclude tests from coverage reports.
- translation: Translation related tools.
TranslationCache: A cache dedicated to translations.
Color: DefinesLocalColorSchemefor light and dark themes.Typography: DefinesLocalTypographyfor the text fonts.Shapes: DefinesLocalShapesfor the shape system.Translations: Composable helpers to read fromTranslationCache. ProvidesLocalTranslationState.Theme: Main app theme definition that uses the previously describedCompositionLocals.Preview: Wrapper composable that the application theme for accurate rendering in previews.- core: UI core components like
Text,Button,Image, etc. that provide the building blocks for composite components. - component: UI composite components for specific features built upon the core components.
- media: Media implementations.
AudioPlayer: Manages playlist deduplication and shuffling.Camera: Manages photo capture and video recording.
- locale: Localization and date/time formatting.
Language: App default language and other language listings.Locale: Methods to get the current system language, locale-aware datetime formatting, and aFlowthat emits on locale changes.Clock: Methods to observe system clock changes and uptime.SynchronizedClock: A clock that provides accurate time while resisting tampering.
- platform: OS-specific APIs.
Development: Development mode flag.AppDataPath: Indicates the absolute path to the application's directory.
- security: Cryptography and UUID utilities.
Cryptography: Cryptography methods like encrypt/decrypt and hashing.
- telemetry: Logging and crash reporting.
PlatformLogger: ATelemetryEngineimplementation for native system console outputs.Console: An in-memory circular buffer for an in-app console feature.
- database:
SQLDelightimplementations, tables and drivers.- adapter: List of adapters used to marshal and map data types to and from a database.
DatabaseFactory: Creates the mainAppDatabasewith the adapters.SqlDriver: Creates the database driver.NoOpSqlDriver: A no-op implementation ofSqlDriver.Database: Database extension helpers.
- http:
Ktorconfiguration and network logic.- plugin: Plugins used by the
KtorHttp engine, like telemetry logging, timeouts, Http caching, encoding and JSON content negotiation. HttpClientFactory: Creates ahttpClientwith the plugins.HttpClientEngine: Creates the Http client engine.NoOpHttpClientEngine: A no-op implementation ofHttpClientEngine. Returns 204 for every request.Network: Network status helpers.Http: Extensions to execute type-safe requests and decode the response into aHttpResult<Success|Error>.
- plugin: Plugins used by the
- notification: Notification system implementation.
Notification: Platform specific implementation of a trigger of a native system notification defined byNotificationData.NotificationPayload: Exposes aFlowused to bridge the Native OS notifications interactions with the common code.
- resource: Resource index.
AudioResource: Sealed class functioning as a resource index for audio incommonMain/resources/tracks.ImageResource: Sealed class functioning as a resource index for images incommonMain/resources/drawable.JsonResource: Sealed class functioning as a resource index for JSONs incommonMain/composeResources/files.ResourceLoader: Loads resources via the Compose Resources API.
- storage: File system access.
File: Declarations for suspending save/load/delete file operations.Storage: ImplementsStorageFileinterface to manage the state and persistence of key-value files. Uses a Mutex-protected I/O to prevent race conditions and a read-only stream as an in-memory cache for faster fetch operations.StorageKey: Sealed class of keys used inStorageFileimplementations.IO: Helper functions to save/load objects.
- device: Device specific code.
Device: Generates and fetches a device installation uuid.
- translation: Translation service.
TranslationService: Service to monitor locale and translations.
- push: Broadcast and Notification services.
BoradcastService: Broadcast service that listens for push notifications.NotificationService: Notifications service that listens for user push notifications.
- scheduler: Scheduler implementation.
Job: A persistent background execution unit handled by the scheduling subsystem.JobFactory: Translates Jobs into executable runtime actions and is implemented byJobProvider.Scheduler: Orchestrates dispatch logic by managing job queues destined for persistent execution and is implemented byJobScheduler.JobResult: Represents the final outcome of aJob.
- usecase: Implementation of specific business workflows. Each use case is a sub-package containing the interface and its gateway implementation.
Gateways: Aggregates all gateways under theUseCasesfile.
App: Main composable that assembles the application UI and acts as the top-level container for the user-facing elements.- lifecycle: Platform-aware lifecycle observers.
Lifecycle: Composable that registers foreground/background callbacks, with an optional recomposition key.
- permission: System permissions management.
PermissionManager: Manager for checking and requesting system permissions declared inPermission. The latter also providesLocalPermissionManager.
- media: Media composables.
AudioPlayer: Registers a lifecycle callback for audio playback. ProvidesLocalAudioPlayer.Camera: Composable for camera previews. ProvidesLocalCamera.
- navigation: Routing logic.
- provider: Definitions of the navigation routes.
- scene: Scene strategies used to render a list of entries.
Screen: Sealed interface that enumerates all destinations.Router: Handles routing logic and manages the navigation backstack. It is implemented byNavigator.NoOpRouter: A no-op implementation ofRouter.Navigation: Composable that sets up the navigation and defines all the possible navigation destinations within the app.Routing: Navigation utility functions.DeepLink: Deep linking utility functions.
- component: UI components implementations. Each component is a sub-package containing the composable and respective store for state management, all co-located.
Store:ViewModelwith aStateFlow<State>as the single source of truth for the UI and reducer override to process actions from the UI.
- screen: These are the UI entry points built with components. Each screen is also sub-package containing the composable and respective store for state management, all co-located.
Screen: Wrapper composable that provides the foundational UI.
Dependency: Manual dependency injection. This class wires everything together. It initializes the full stack:SqlDriver→AppDatabase,HttpClientEngine→HttpClient, to be used by theSchedulerandUseCasesviaGateways.Application: Entry point of the application. It initializes theDependencygraph and other app services. Provides dependencyFlows that modules can observe to ensure initialization is complete.
- config: Server configs.
ServerFlagsandServerConfigsare containers for feature flags and configurations respectively. - platform: OS-specific APIs.
Env: Environment variables definitions.Property: Property definitions.
- security: Cryptography and UUID utilities.
Cryptography: Cryptography methods to hash and verify passwords.TicketManager: Manages single-use, short-lived tickets for connections.
- telemetry: Logging and crash reporting.
ServerLogger: ATelemetryEngineimplementation for server outputs.
- database:
Exposedimplementations and tables.- table: Database tables definitions.
DatabaseFactory: Creates aR2dbcDatabase.Database: Database extension helpers.
- http:
Ktorconfiguration and network logic.- plugin: Plugins used by the
KtorHttp engine. HttpServerFactory: Sets the Http Server with the plugins.
- plugin: Plugins used by the
- push: Broadcast and Notification services.
BoradcastService: Broadcast service to send push notifications.NotificationService: Notifications service to send user push notifications.
- route: Defines the routing endpoints for the server.
- provider: Aggregates the routes.
Routing: Sets all routes defined in theproviderpackage.
- usecase: Implementation of specific business workflows. Each use case is a sub-package containing the interface and its gateway implementation.
Gateways: Aggregates all gateways under theUseCasesfile.
Fake data and demo setup.
Dependency: Manual dependency injection. This class initializes theR2dbcDatabaseto be used byUseCasesviaGateways.Application: Entry point of the server application. It initializes theDependencygraph and other app services.
By default, signing keys and some envs are read from a local.properties file in the root directory.
Change this method at your leisure.
- Android Signing:
android.storeFile,android.keyAlias,android.keyPassword,android.storePassword. - Mac Notarization:
mac.sign.identity,mac.notarization.appleId,mac.notarization.teamId,mac.notarization.password. - Sentry Monitoring:
sentryDsnfor production monitoring. - Environment Variables:
server.port,server.jwt.issuer,server.jwt.audience,server.jwt.secret
New features should be gated behind a flag. Below are the steps to create a new isolated feature.
- Domain: Add the domain file(s) in the
domainpackage. - Entity: Add the entity to
EntityTypefor offline support.
- UI Components: Add the needed components.
- Flag: Add in
ClientFlags.ktthe feature:val {feature}: Boolean. - Components with Store: Create a new package
ui/component/{component}with files{Component},{Component}StateAction,{Component}Store. - Screen: Create a new package
ui/screen/{feature}with files{Feature}Screen,{Feature}StateAction,{Feature}Store. - UseCase: Create the use cases in
domain/usecaseand the needed.sqfiles in the foldersqldelight. - Scheduler: Create the needed jobs in
domain/scheduler/JobProviderfor offline capabilities. - Navigation: Add to the
ui/navigationpackage the new screen in the sealed classScreen, the new route in theproviderpackage and the link it in the composable navigation inNavigation, gated by the feature flag.
- Flag: Add in
ServerFlags.ktthe feature:val {feature}: Boolean. - Database: Add the feature table to
data/database/tableand to the table list inDatabase. - UseCase: Create the use cases in
domain/usecase. - Route: Add the feature routes in
domain/routein theproviderpackage and the link it in theRoutingfile, gated by the feature flag.
Compose screens use a custom MVI Store pattern. Each screen has a Store ({Screen}Store) and a Screen composable ({Screen}Screen). Jobs inside a Store can be launched with an ID to cancel/replace prior work.
The UI state uses kotlinx.collections.immutable (ImmutableList, ImmutableSet) to prevent accidental mutations.
Store<State, Action> (extends ViewModel) is the base class:
stateFlow: StateFlow<State>— single source of truth observed by the composablesend(action)→reducer(state, action)— the only entry point for UI eventsupdateState { }— the only way to mutate statelaunch(id, replace, context) { }— launches a coroutine tied to an IDFlow<T>.observe(id) { }— lifecycle-aware collection that pauses after the UI unbinds and resumes when it reattaches
Translations are defined in the server file main/resources/static/translations.json.
This file is fetched by the client TranslationService, which also caches the translations in the TranslationCache.
Default translations are however bundled with the client in a mirror file commonMain/composeResources/files/translations.json in case of network unavailability.
So adding new translations is as simple as adding new entries to the server file and optionally to the client file (recommended).
The translations.json file represents a standard localization (i18n) dictionary. It maps string keys to their corresponding human-readable text for a specific language.
Each object in the array defines a single translated string and consists of three properties:
- languageIso: The ISO 639-1 two-letter language code identifying the language of the string (e.g., "en", "pt").
- key: The unique identifier used within the codebase to request this specific piece of text (e.g., hello_world).
- value: The actual localized text that will be displayed to the user on the UI (e.g., "Hello World").
So a multi-language translation file would look like:
[
{
"languageIso": "en",
"key": "hello_world",
"value": "Hello World"
},
{
"languageIso": "pt",
"key": "hello_world",
"value": "Olá Mundo"
},
{
"languageIso": "es",
"key": "hello_world",
"value": "Hola Mundo"
}
]
The Translations.kt file offers helpers to collect the translation cache state, get and inject translations.
In the composables, call getTranslation(key, args) to get the localized string.
The language ISO is handled automatically, only the key is needed; args is used to populate positional placeholders defined in the translation. Example:
{
"languageIso": "en",
"key": "translation_with_arguments",
"value": "I have 2 arguments %1$s and %2$s"
}
Calling:
getTranslation("translation_with_arguments", "one", "two")
Returns:
I have 2 arguments one and two.
To inject translations in the cache and bypass the TranslationService, call InjectTranslations(translations).
This is specially useful for composable previews.
On Android, use the applicationContext provided via KInitializer to avoid explicit context injection.
The testing structure mirrors the source code to ensure 1:1 coverage.
Coverage excludes @Serializable classes, @Preview composables, and anything annotated with @ExcludeFromTesting.
Write tests in commonTest whenever possible. Platform-specific test setup lives in the PlatformTestCase expect/actual classes.
- Unit/UI Tests: Should run in the
commonTestsource set whenever possible. Use theTestCaseharness for hermetic Unit/UI testing with mock engines. - Coverage: Kover is configured to verify a minimum of 90% code coverage.
- Command: Run all tests using
./gradlew clean testClientAndReport.
All client tests extend TestCase:
dependency—Dependencywith an in-memorySQLDelightdriver and a mockKtorenginerunUnitTest { }— Sets up the test environment, resets all data, runs unit test then tears downrunUITest { }— Same thing but for UI test. UsesetUI { }to render composables underAppThemewith mock lifecycle and navigation
Coverage excludes @Serializable classes and anything annotated with @ExcludeFromTesting.
- Tests: Use the
TestCaseharness for hermetic testing with an Http test client. - Coverage: Kover is configured to verify a minimum of 90% code coverage.
- Command: Run all tests using
./gradlew clean testServerAndReport.
All server tests extend TestCase:
dependency—Dependencywith an in-memoryR2dbcDatabaserunTest { }— Sets up the test environment, resets all data, runs test then tears down
These are the declarations that resolve to a distinct implementation per source set.
- SqlDriver: Creates a SQLDelight async database driver.
- HttpClientEngine: Creates a Ktor HTTP engine.
- Network: Checks internet connectivity and exposes a
Flow<Boolean>for real-time state changes. - File: IO operations on the local filesystem.
- AppDataPath: Resolves the absolute path to the application's data directory.
- Development: A boolean flag indicating debug mode.
- Cryptography: Encrypt, decrypt, and hash string content.
- PlatformLogger: A
TelemetryEnginethat writes to the native system console. - AudioPlayer: Creates a platform audio player for playlist control.
- Locale: Reads the system language, formats UTC dates for the local timezone, and emits a
Flowon locale changes. - Lifecycle: Composable that registers foreground/background callbacks.
- Platform: Runtime metadata (OS name, version, brand, model).
- Dispatcher: Provides the I/O
CoroutineDispatcher. - VerticalScrollBar: Renders a styled scrollbar alongside a
LazyList. - PlatformTestCase: Abstract base class for platform-specific test setup.
Bump versions in:
build-logic/convention/src/main/kotlin/Shared.kt- appVersion, appVersionNumber, appServerVersion
iosApp/iosApp/Info.plist- CFBundleShortVersionString, CFBundleVersion
iosApp/iosApp.xcodeproj/project.pbxproj- MARKETING_VERSION, CURRENT_PROJECT_VERSION
Standard compile commands for different platforms include:
- iOS: Xcode archive
- Android:
./gradlew clean bundleRelease - Mac:
./gradlew clean notarizeDmg --no-configuration-cache - Windows:
./gradlew clean packageMsi - Linux:
./gradlew clean packageDeb - Web:
./gradlew clean deployWeb - Server:
../gradlew :server:buildFatJar
The offline engine is the mechanism that makes the app Offline First. It provides a persistent, fault-tolerant job queue backed by the database that continues executing background work when connectivity is interrupted, and resumes automatically when the device comes back online.
A Job is the atomic unit of work. Every background operation is modeled as a Job before it touches the network.
Key properties:
entityType: EntityType— the domain entity the job operates on (e.g.TRANSLATION,SESSION)entityUuid: Uuid?— the specific entity being mutated;nullfor global collection fetchestype: Type— the operation intent:GET,POST, orDELETEconflictPolicy: ConflictPolicy— how to handle duplicate jobs; defaults toIGNOREforGETandAPPENDforPOST/DELETEpayload: String?— a JSON string carrying the execution parametersstate: State— the current lifecycle position:PENDING→RUNNING→COMPLETE/FAILEDattempt: Int— a zero-indexed retry counter incremented on each failure
When a job is queued, the scheduler checks the ConflictPolicy before writing to disk:
APPEND— enqueue the new job as-is, alongside any pending duplicates (default forPOSTandDELETE)REPLACE— cancel all pending duplicates, then enqueue the new jobIGNORE— skip if an identical job is already pending (default forGET)
Jobs are grouped into logical chains identified by (userUuid, entityType, entityUuid).
Within a chain, only the oldest pending job runs at a time, ensuring sequential mutation ordering for the same entity without blocking unrelated entities.
There are two job categories with distinct execution rules:
- Targeted jobs (
POST,DELETE,entityUuid != null) — Mutations on a specific entity. Jobs for Entity A execute sequentially while jobs for Entity B run in parallel on a separate chain. - Global jobs (
GET,entityUuid == null) — Full collection syncs. A global job will not start if any pending or actively running targeted jobs exist for the sameentityType. This guarantees local mutations are flushed to the server before the app pulls the latest remote state, preventing optimistic writes from being overwritten.
The scheduler combines the pending jobs database stream with a Network stream to gate execution.
When the device goes offline, all dispatching pauses. When connectivity is restored the stream resumes automatically.
To run the client in a fully disconnected mode, swap out HttpClientEngine with NoOpHttpClientEngine (which returns 204 for every request) or
set the feature flag http = false to make all outbound requests throw immediately without touching the network.
JobResult expresses the outcome of each execution:
Success/NoOp→ job transitions toCOMPLETERetry→ attempt counter incremented; job reverts toPENDINGif belowschedulerMaxAttempts, otherwise transitions toFAILEDError→ terminal failure, job transitions toFAILED
Network exceptions are automatically converted to Retry rather than Error, so transient connectivity drops do not permanently fail jobs.
If the OS kills the app while a job is RUNNING, that job would otherwise remain stuck forever.
On every startup, all orphaned RUNNING jobs are reset back to PENDING before the scheduler loop begins.
Add a local.properties to the project root and fill in the required keys.
See Environment Setup for the full list.
Kotlin Multiplatform support in popular DI frameworks adds complexity and limitations across all target platforms.
The Dependency class in each module wires the full stack explicitly, which keeps the initialization path transparent and avoids generated code issues particularly on WASM.
Yes. The shared module is the single source of truth for domain models used by both sides.
Models are annotated with @Serializable and sent over the wire directly; no separate DTO layer is needed.
The client maps between domain models and the local database schema in each use case package, but the API contract itself is the domain model.
Declare the expect in commonMain, then provide actual implementations in each relevant source set.
See Platform Specific Implementations for the existing list of declarations.
That is part of the beauty of KMP. How much code should be written in Kotlin and platform native code is up to you!
Use the PermissionManager to check and request permissions.
Add the entity to EntityType, create the corresponding use cases in domain/usecase, then register a new Job in JobProvider that delegates to those use cases.
The scheduler will persist and retry the job automatically when connectivity is restored.
The client writes locally first (optimistic write) and queues a background Job for the server sync.
When the server response arrives, modifiedAt timestamps are compared, and the incoming version is saved only if it is equal to or newer than the local one (remote.modifiedAt >= local.modifiedAt).
Batch syncs perform the same comparison in memory across a chunked local query, then commit only the outdated rows in a single atomic transaction.
Scheduling conflicts are governed by Job.ConflictPolicy: typically, GET jobs use IGNORE (skip if an identical fetch is already pending) and POST/DELETE jobs use APPEND (queue normally, preserving the full operation history).
Yes. Implement your own if you like. This is a starter kit. Or swap out HttpClientEngine with NoOpHttpClientEngine or just disable the flag ClientFlags.http to run the client in a fully disconnected mode.
This is also how the test harness (TestCase) isolates client tests from the real network.
Automating this part is still a work in progress. For now, you need to update it manually. See Deployment & Distribution.
Autonomously in the code? No. As an assistant to write documentation? Sure. As an advanced search engine? Also yes.
To be clear, I am not against AI in code, but a supporter of a strict boundary between human-authored and AI-generated code, especially in the foundational layer. This prevents architectural drifts in my experience. Therefore, I strongly advocate for placing AI generated code into completely separate modules, to ensures a clear, undeniable boundary between the human-crafted and AI outputs.
In this project, the foundational code is human authored. Again, it does not mean that AI was not used as a helper to get to a solution or as a productivity multiplier. I recommend using AI to generate isolated, repeatable features, and their tests, provided they follow the project's established patterns, and are always human reviewed. Moreover, the design module is a great candidate for AI-assisted development as an isolated design system where all UI components live.