Offline Notes App (CRUD with Sync Queue)
The problem
Build a notes app: create, edit and delete notes, synced with a server.
Requirements:
- Everything works fully offline; changes sync when the network returns.
- No lost edits if the app is killed mid-sync.
- Explain your conflict policy when the same note was edited on two devices.
Stuck? Open one hint
Give every note a client id and a sync status. The server id arrives later — it cannot be the primary key.
Show the worked solution
This is the offline-first design round. Build the answer around three ideas.
1. Local id is the identity. The note gets a UUID generated on the device; serverId is nullable and filled in after the first successful sync. If you key on the server id, creating a note offline is impossible.
2. Every row carries a status. pendingCreate, pendingUpdate, pendingDelete, synced. Deletes are soft until confirmed, so a failed sync does not resurrect or lose the note. The UI hides soft-deleted rows.
3. A sync worker drains the queue. WorkManager with a network constraint, picking up everything not synced, in order. Requests carry the client id, so a retry after a dropped response is idempotent rather than creating duplicates.
For conflicts, state a policy and its cost out loud: last-write-wins on updatedAt is simple but can silently lose an edit; keeping both versions and letting the user choose is safer but needs UI. Picking one and naming the trade-off is the answer — not having a policy is the failure.
@Entity(tableName = "note")
data class NoteEntity(
@PrimaryKey val id: String = UUID.randomUUID().toString(), // client id
val serverId: String? = null,
val title: String,
val body: String,
val updatedAt: Long,
val status: SyncStatus = SyncStatus.PENDING_CREATE,
val isDeleted: Boolean = false,
)
enum class SyncStatus { SYNCED, PENDING_CREATE, PENDING_UPDATE, PENDING_DELETE }
// writes are always local first
suspend fun save(note: NoteEntity) {
dao.upsert(
note.copy(
updatedAt = System.currentTimeMillis(),
status = if (note.serverId == null) SyncStatus.PENDING_CREATE else SyncStatus.PENDING_UPDATE,
)
)
SyncWorker.enqueue(context) // drains later, with a network constraint
}
class SyncWorker(ctx: Context, p: WorkerParameters) : CoroutineWorker(ctx, p) {
override suspend fun doWork(): Result {
val pending = dao.pending()
for (note in pending) {
runCatching {
when (note.status) {
SyncStatus.PENDING_DELETE -> { api.delete(note.serverId!!); dao.hardDelete(note.id) }
else -> {
val saved = api.save(note.toDto()) // client id sent -> idempotent retry
dao.upsert(note.copy(serverId = saved.id, status = SyncStatus.SYNCED))
}
}
}.onFailure { return Result.retry() }
}
return Result.success()
}
}