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:

  1. Always read from the local database — the UI never waits for network
  2. Track which notes are synced — a new synced column in SQLDelight
  3. Push unsynced changes to the server when online
  4. Pull remote changes and merge into the local database
  5. 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:

  • insertNote defaults to synced = false — local changes are unsynced until pushed
  • insertNoteFromRemote always sets synced = 1L — remote data is already synced
  • updateNote resets synced = 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

  1. User creates a note → saved locally with synced = 0
  2. UI shows the note immediately — no network wait
  3. App calls sync() → pushes unsynced notes to server, pulls remote changes
  4. Network unavailable? → the catch block returns SyncResult.Error, local data is untouched
  5. Next time onlinesync() picks up where it left off (unsynced notes still have synced = 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 launchviewModelScope.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

ComponentWhat It Does
NoteDtoJSON-serializable DTO with snake_case field mapping
NoteApiClientKtor HTTP client with GET, POST, PUT, DELETE endpoints
synced columnTracks which notes need to be pushed to server
SyncResultSealed class for success/error sync outcomes
sync() methodPush unsynced → pull remote → handle errors
Migration 1.sqmAdds 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).