In the previous tutorial, we set up the Notes app architecture with a local database and shared ViewModels. Everything works offline. But real apps need to sync with a server.
In this tutorial, we build the shared data layer — adding a Ktor API client, Data Transfer Objects (DTOs), an offline-first sync pattern, and a database migration. All of this code lives in commonMain and runs on both Android and iOS.
What We Are Building
By the end of this tutorial, the data layer will:
- Always read from the local database — the UI never waits for network
- Track which notes are synced — a new
syncedcolumn in SQLDelight - Push unsynced changes to the server when online
- Pull remote changes and merge into the local database
- Handle errors gracefully — if the API is down, local data stays intact
This is the offline-first pattern: local database is the source of truth, remote API is for backup and sync.
Architecture
┌──────────────┐ ┌──────────────┐
│ UI Layer │ │ UI Layer │
│ (Compose) │ │ (SwiftUI) │
└──────┬───────┘ └──────┬───────┘
│ │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ NoteRepository │
│ (offline-first) │
└────┬───────────┬────┘
│ │
┌────────▼──┐ ┌─────▼────────┐
│ Local │ │ Remote │
│ SQLDelight│ │ Ktor API │
└───────────┘ └──────────────┘
The repository always reads from the local database. Sync is a separate operation that pushes local changes up and pulls remote changes down.
Step 1: Create the Data Transfer Object
The API returns JSON with snake_case keys. Our domain Note model uses camelCase. A DTO bridges the two:
// shared/src/commonMain/.../data/remote/NoteDto.kt
@Serializable
data class NoteDto(
val id: Long,
val title: String,
val content: String,
@SerialName("created_at") val createdAt: Long,
@SerialName("updated_at") val updatedAt: Long,
@SerialName("is_favorite") val isFavorite: Boolean = false,
val color: String = "DEFAULT"
)
Why use a separate DTO instead of annotating the domain model?
- Separation of concerns — the domain model represents your app’s data; the DTO represents the API’s data
- API changes don’t break the UI — if the API adds or renames a field, only the DTO and mapper change
- Different defaults — the API might use different default values or types
Mapper Functions
Extension functions convert between DTO and domain model:
fun NoteDto.toNote(): Note = Note(
id = id,
title = title,
content = content,
createdAt = Instant.fromEpochMilliseconds(createdAt),
updatedAt = Instant.fromEpochMilliseconds(updatedAt),
isFavorite = isFavorite,
color = try {
NoteColor.valueOf(color)
} catch (_: IllegalArgumentException) {
NoteColor.DEFAULT
}
)
fun Note.toDto(): NoteDto = NoteDto(
id = id,
title = title,
content = content,
createdAt = createdAt.toEpochMilliseconds(),
updatedAt = updatedAt.toEpochMilliseconds(),
isFavorite = isFavorite,
color = color.name
)
The try/catch on NoteColor.valueOf() handles the case where the server returns an unknown color value — we fall back to DEFAULT instead of crashing.
Step 2: Build the Ktor API Client
The NoteApiClient wraps all HTTP calls. It lives in commonMain — Ktor handles platform differences internally (OkHttp on Android, Darwin on iOS):
// shared/src/commonMain/.../data/remote/NoteApiClient.kt
class NoteApiClient(private val httpClient: HttpClient) {
private val baseUrl = "https://api.example.com/notes"
suspend fun fetchAllNotes(): List<NoteDto> =
httpClient.get(baseUrl).body()
suspend fun fetchNoteById(id: Long): NoteDto =
httpClient.get("$baseUrl/$id").body()
suspend fun createNote(dto: NoteDto): NoteDto =
httpClient.post(baseUrl) {
contentType(ContentType.Application.Json)
setBody(dto)
}.body()
suspend fun updateNote(dto: NoteDto): NoteDto =
httpClient.put("$baseUrl/${dto.id}") {
contentType(ContentType.Application.Json)
setBody(dto)
}.body()
suspend fun deleteNote(id: Long) {
httpClient.delete("$baseUrl/$id")
}
}
All functions are suspend — they run in a coroutine and do not block the UI thread. Ktor’s body() extension automatically deserializes JSON using kotlinx-serialization.
Configure the HttpClient in Koin
The HttpClient is a singleton, configured with the ContentNegotiation plugin for JSON:
// SharedModule.kt
val sharedModule = module {
single {
HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = false
isLenient = true
})
}
}
}
singleOf(::LocalNoteDataSource)
singleOf(::NoteApiClient)
singleOf(::NoteRepository)
factoryOf(::NotesViewModel)
factoryOf(::NoteDetailViewModel)
}
ignoreUnknownKeys = true is important — if the server adds a new field, the app will not crash. It simply ignores fields that are not in the DTO.
Step 3: Add the Synced Column
We need to track which notes have been synced with the server. Add a synced column to the SQLDelight schema:
CREATE TABLE NoteEntity (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
is_favorite INTEGER NOT NULL DEFAULT 0,
color TEXT NOT NULL DEFAULT 'DEFAULT',
synced INTEGER NOT NULL DEFAULT 0
);
And add new queries:
getUnsyncedNotes:
SELECT * FROM NoteEntity WHERE synced = 0;
markSynced:
UPDATE NoteEntity SET synced = 1 WHERE id = ?;
The insertNote and updateNote queries now include the synced parameter:
insertNote:
INSERT INTO NoteEntity (title, content, created_at, updated_at, is_favorite, color, synced)
VALUES (?, ?, ?, ?, ?, ?, ?);
updateNote:
UPDATE NoteEntity SET title = ?, content = ?, updated_at = ?,
is_favorite = ?, color = ?, synced = ?
WHERE id = ?;
Database Migration
For existing users who created the database before this column existed, add a SQLDelight migration file:
-- shared/src/commonMain/resources/db/migrations/1.sqm
ALTER TABLE NoteEntity ADD COLUMN synced INTEGER NOT NULL DEFAULT 0;
SQLDelight tracks the schema version and runs migration files automatically when the database version changes.
Step 4: Update the Local Data Source
The LocalNoteDataSource gets three new methods and updated signatures:
class LocalNoteDataSource(private val db: NotesDatabase) {
private val queries = db.noteQueries
// ... existing getAllNotes, getNoteById, searchNotes, getFavoriteNotes ...
fun getUnsyncedNotes(): List<Note> =
queries.getUnsyncedNotes().executeAsList().map { it.toNote() }
fun insertNote(
title: String,
content: String,
color: NoteColor = NoteColor.DEFAULT,
synced: Boolean = false
): Long {
val now = Clock.System.now().toEpochMilliseconds()
queries.insertNote(
title = title, content = content,
created_at = now, updated_at = now,
is_favorite = 0L, color = color.name,
synced = if (synced) 1L else 0L
)
return queries.getLastInsertId().executeAsOne()
}
fun insertNoteFromRemote(note: Note) {
queries.insertNote(
title = note.title, content = note.content,
created_at = note.createdAt.toEpochMilliseconds(),
updated_at = note.updatedAt.toEpochMilliseconds(),
is_favorite = if (note.isFavorite) 1L else 0L,
color = note.color.name, synced = 1L
)
}
fun updateNote(note: Note, synced: Boolean = false) {
val now = Clock.System.now().toEpochMilliseconds()
queries.updateNote(
title = note.title, content = note.content,
updated_at = now,
is_favorite = if (note.isFavorite) 1L else 0L,
color = note.color.name,
synced = if (synced) 1L else 0L,
id = note.id
)
}
fun markSynced(id: Long) {
queries.markSynced(id)
}
// ... deleteNote unchanged
}
Key decisions:
insertNotedefaults tosynced = false— local changes are unsynced until pushedinsertNoteFromRemotealways setssynced = 1L— remote data is already syncedupdateNoteresetssynced = false— any local edit needs to be re-synced
Step 5: Implement the Offline-First Repository
The NoteRepository now takes both a local data source and a remote API client:
class NoteRepository(
private val localDataSource: LocalNoteDataSource,
private val apiClient: NoteApiClient
) {
// All reads come from local database — fast, works offline
fun getAllNotes(): Flow<List<Note>> = localDataSource.getAllNotes()
fun getNoteById(id: Long): Flow<Note?> = localDataSource.getNoteById(id)
fun searchNotes(query: String): Flow<List<Note>> = localDataSource.searchNotes(query)
// All writes go to local database with synced = false
fun createNote(title: String, content: String, color: NoteColor = NoteColor.DEFAULT): Long =
localDataSource.insertNote(title, content, color, synced = false)
fun updateNote(note: Note) =
localDataSource.updateNote(note, synced = false)
fun deleteNote(id: Long) =
localDataSource.deleteNote(id)
suspend fun sync(): SyncResult {
return try {
// Step 1: Push unsynced notes to the server
val unsyncedNotes = localDataSource.getUnsyncedNotes()
for (note in unsyncedNotes) {
apiClient.createNote(note.toDto())
localDataSource.markSynced(note.id)
}
// Step 2: Fetch remote notes and merge into local DB
val remoteNotes = apiClient.fetchAllNotes().map { it.toNote() }
for (remoteNote in remoteNotes) {
localDataSource.insertNoteFromRemote(remoteNote)
}
SyncResult.Success(pushed = unsyncedNotes.size, pulled = remoteNotes.size)
} catch (e: Exception) {
SyncResult.Error(e.message ?: "Sync failed")
}
}
}
sealed class SyncResult {
data class Success(val pushed: Int, val pulled: Int) : SyncResult()
data class Error(val message: String) : SyncResult()
}
How Offline-First Works
- User creates a note → saved locally with
synced = 0 - UI shows the note immediately — no network wait
- App calls
sync()→ pushes unsynced notes to server, pulls remote changes - Network unavailable? → the
catchblock returnsSyncResult.Error, local data is untouched - Next time online →
sync()picks up where it left off (unsynced notes still havesynced = 0)
The UI never blocks on network. The user can create, edit, and delete notes on an airplane. Everything syncs when connectivity returns.
Simplification note: This sync logic is intentionally simplified for the tutorial. A production app would need: (1) upsert logic to avoid duplicate inserts when pulling remote notes, (2) server-generated IDs mapped to local IDs, (3) per-note error handling instead of aborting the entire sync on one failure, and (4) conflict resolution when the same note is edited on two devices. Libraries like Store can handle these complexities.
When to Call sync()
You can trigger sync in several ways:
- On app launch —
viewModelScope.launch { repository.sync() } - Pull-to-refresh — user manually requests sync
- After a write operation — optimistic push after creating/editing a note
- On network state change — when the device reconnects to the internet
We will wire up the sync trigger in the UI layer (Part 17).
Build and Run
Both platforms should build:
# Android
./gradlew :composeApp:assembleDebug
# iOS (shared framework)
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
The app still works as before — all reads come from the local database. The sync feature is ready but not triggered from the UI yet (that comes in Part 17).
What We Built
| Component | What It Does |
|---|---|
NoteDto | JSON-serializable DTO with snake_case field mapping |
NoteApiClient | Ktor HTTP client with GET, POST, PUT, DELETE endpoints |
synced column | Tracks which notes need to be pushed to server |
SyncResult | Sealed class for success/error sync outcomes |
sync() method | Push unsynced → pull remote → handle errors |
| Migration 1.sqm | Adds synced column for existing databases |
Source Code
Full source code for this tutorial: GitHub — tutorial-16-data-layer
What’s Next?
In the next tutorial, we build the UI layer — a proper note editing screen, navigation between list and detail, and wiring up the sync trigger on both Android (Compose) and iOS (SwiftUI).