androidinterview.com

Android System Design Interview Questions

Design a Google Notes app.

Tier: CommonDifficulty: Hard

A notes app looks small and is not. The interesting part is not the list screen, it is that the same note can be edited on two devices while both are offline, and both edits have to survive. Say that early, because it tells the interviewer you know where the difficulty is.

What to clarify first

  • Does it sync across devices, or is one device enough. This single answer decides most of the design.
  • What can a note hold. Plain text is one problem, checklists and images and drawings are three more.
  • Is it collaborative, meaning two people in one note at the same time, or just one user across their own devices. Real time collaboration is a much harder problem and interviewers often do not want it.
  • Do we need search, reminders, labels, and archiving, or is that out of scope.
  • Roughly how many notes per user. A few hundred and a few hundred thousand lead to different local storage decisions.

The shape of it

The local database is the source of truth, not the network. The UI only ever reads from Room and never waits on a request, which is what makes the app usable on a train.

  • Room holds notes, and every row carries a server revision and a dirty flag. The dirty flag is a column on the Room row, not a field on the note the rest of the app sees.
  • The repository exposes notes as a Flow. The UI observes it and knows nothing about syncing.
  • A sync engine pushes dirty rows and pulls changes since the last server cursor. It runs through WorkManager so it survives the process dying and can wait for connectivity.
  • Attachments are files on disk with a row pointing at them, uploaded separately from the note body, because a 4 MB image should never block a text edit from syncing.
  • Search is a Room FTS table kept in step with the notes table, so search works offline too.

A write goes to Room first, the UI updates immediately from the Flow, and the sync engine catches up later. The user never watches a spinner to type.

The tradeoffs worth naming

How you resolve a conflict is the real question. Three honest options.

  • Last write wins on the whole note. Trivial to build, and it silently throws away someone's edit. Fine for a prototype, embarrassing in production.
  • Field level merge, where title and body and checklist items sync separately. Much better in practice, because two people editing different parts of a note both keep their work, and it is a reasonable amount of code. When both sides edited the same field, the server copy wins and the local edit is lost. That is the case to name out loud, and the usual next step is keeping the loser as a conflict copy rather than dropping it.
  • An operation log, where you sync the edits rather than the result. This is what gets you real merging, and it is a lot of machinery. Say you would only reach for it if collaboration were in scope.

Field level merge is usually the right answer to give, with the reason attached.

Sync granularity is the other one. Syncing whole notes is simple and wastes bandwidth on a one character change. Syncing diffs is efficient and much harder to get right.

The code

The merge is the decision, so the merge is what the code shows. Three inputs go in, the last synced copy, the local edit, and whatever the server holds now. One four line rule picks a winner field by field. Two inputs cannot do this, because with only the local and the remote copy there is no way to tell an edited field from an untouched one, and that is the sentence to say out loud. A note this device has never seen has no base and no local copy, so it is simply the remote copy, and that is the whole of a first sync.

The sync engine around it is short because every interesting decision already lives in the merge. It pushes dirty rows first, pulls a page at a time, and runs both results through the same merge. The Room DAOs, the attachment uploader and the FTS table are left out, none of them change the design.

Java

com.androidinterview.notes.data.NoteStore.java

package com.androidinterview.notes.data;

import com.androidinterview.notes.model.Note;
import java.util.List;

// Room sits behind this in a real app, two tables, the notes the user edits
// and the last synced copy of each. The dirty flag lives on the Room row, not
// on the domain model. The sync engine only ever sees this interface, so it
// runs in a plain unit test with no device attached.
public interface NoteStore {

    Note local(String id);   // what the screen shows and the user edits, null if never seen here

    Note base(String id);    // the last copy pulled from the server, null if never pulled

    List<Note> dirty();      // edited since the last successful push

    // Merged row and its new base, written in one transaction. Two writes
    // would leave a crash between them looking like an unsynced edit forever.
    void apply(Note merged);

    long cursor();

    void cursor(long value);
}

com.androidinterview.notes.model.Note.java

package com.androidinterview.notes.model;

// One row of the notes table. The ordering field is revision, which the server
// owns, never a device clock. A phone with the wrong date would otherwise win
// every conflict it took part in.
//
// A delete is a tombstone rather than a missing row. Drop the row and the next
// pull from another device happily resurrects the note.
public record Note(
        String id,
        String title,
        String body,
        long revision,
        long deletedAt) {

    public boolean deleted() {
        return deletedAt > 0;
    }
}

com.androidinterview.notes.sync.FieldMerge.java

package com.androidinterview.notes.sync;

import com.androidinterview.notes.model.Note;

// The conflict rule, and the only genuinely hard decision in a notes app.
//
// Last write wins on the whole note is one line and silently throws away
// somebody's paragraph. An operation log merges properly and is a lot of
// machinery. Field level merge sits in between, and it wins the common case,
// two devices touching different parts of the same note.
public final class FieldMerge {

    private FieldMerge() {
    }

    // Three inputs, not two. base is the last copy this device pulled from the
    // server, local is what the user has edited since, remote is what the
    // server holds now. Without base there is no way to tell an edited field
    // from an untouched one, which is why a two way merge cannot do this.
    public static Note merge(Note base, Note local, Note remote) {
        // A note this device has never seen is just the remote copy. This is
        // every row of the first sync on a new device.
        if (base == null || local == null) return remote;

        if (local.deleted() || remote.deleted()) {
            // A delete beats an edit. Arguable, and worth saying out loud that
            // it is a product call rather than a technical one.
            long at = Math.max(local.deletedAt(), remote.deletedAt());
            return new Note(remote.id(), remote.title(), remote.body(), remote.revision(), at);
        }
        return new Note(
                remote.id(),
                pick(base.title(), local.title(), remote.title()),
                pick(base.body(), local.body(), remote.body()),
                remote.revision(),
                0);
    }

    // Four lines carry the whole policy. Only the last one loses an edit, and
    // naming that case honestly is most of the answer.
    private static String pick(String base, String local, String remote) {
        if (local.equals(remote)) return local;   // nobody disagrees
        if (local.equals(base)) return remote;    // only the server moved
        if (remote.equals(base)) return local;    // only this device moved
        return remote;                            // both moved, the server wins, the local edit is lost
    }
}

com.androidinterview.notes.sync.NoteSync.java

package com.androidinterview.notes.sync;

import com.androidinterview.notes.data.NoteStore;
import com.androidinterview.notes.model.Note;
import java.util.List;

// One pass of sync. WorkManager runs it with a network constraint in a real
// app, so it survives the process dying and resumes when connectivity returns.
// Nothing here knows that, which is the point of keeping the engine plain.
public final class NoteSync {

    // Retrofit sits behind this. push hands the server the revision this edit
    // was based on, so a stale base comes back as the server's current note
    // rather than overwriting it.
    public interface NoteApi {
        Note push(Note note, long baseRevision);

        Page since(long cursor);

        record Page(List<Note> notes, long cursor, boolean hasMore) {
        }
    }

    private final NoteStore store;
    private final NoteApi api;

    public NoteSync(NoteStore store, NoteApi api) {
        this.store = store;
        this.api = api;
    }

    public void syncOnce() {
        push();
        pull();
    }

    // Push first, so a note the user typed a second ago reaches the server
    // before a pull can merge over it.
    private void push() {
        for (Note local : store.dirty()) {
            Note base = store.base(local.id());
            Note server = api.push(local, base == null ? 0 : base.revision());
            // local is read again after the round trip. A keystroke typed while
            // the push was in flight is then the local side of the merge rather
            // than something the server's copy silently overwrites.
            store.apply(FieldMerge.merge(base, store.local(local.id()), server));
        }
    }

    // Then pull everything changed since the cursor, one page at a time. A new
    // device asking for a hundred thousand notes in one response times out, so
    // the cursor advances per page and a crash halfway costs one page, not the
    // whole sync.
    private void pull() {
        NoteApi.Page page = api.since(store.cursor());
        while (true) {
            for (Note remote : page.notes()) {
                store.apply(FieldMerge.merge(store.base(remote.id()), store.local(remote.id()), remote));
            }
            store.cursor(page.cursor());
            if (!page.hasMore()) return;
            page = api.since(page.cursor());
        }
    }
}

Kotlin

com.androidinterview.notes.data.NoteStore.kt

package com.androidinterview.notes.data

import com.androidinterview.notes.model.Note

// Room sits behind this in a real app, two tables, the notes the user edits
// and the last synced copy of each. The dirty flag lives on the Room row, not
// on the domain model. The engine sees only this interface, so it runs in a
// plain unit test with no device.
interface NoteStore {
    fun local(id: String): Note?   // what the screen shows, null if never seen here
    fun base(id: String): Note?    // the last copy pulled, null if never pulled
    fun dirty(): List<Note>

    // Merged row and its new base in one transaction. Two writes would leave a
    // crash between them looking like an unsynced edit forever.
    fun apply(merged: Note)

    var cursor: Long
}

com.androidinterview.notes.model.Note.kt

package com.androidinterview.notes.model

// One row of the notes table. The ordering field is revision, which the server
// owns, never a device clock. A phone with the wrong date would otherwise win
// every conflict it took part in.
//
// A delete is a tombstone rather than a missing row. Drop the row and the next
// pull from another device happily resurrects the note.
data class Note(
    val id: String,
    val title: String,
    val body: String,
    val revision: Long,
    val deletedAt: Long = 0,
) {
    val deleted: Boolean get() = deletedAt > 0
}

com.androidinterview.notes.sync.FieldMerge.kt

package com.androidinterview.notes.sync

import com.androidinterview.notes.model.Note

// Three inputs, not two. base is the last copy this device pulled, local is
// what the user edited since, remote is what the server holds now. Without
// base there is no way to tell an edited field from an untouched one, which is
// why a two way merge cannot do this at all.
fun merge(base: Note?, local: Note?, remote: Note): Note {
    // A note this device has never seen is just the remote copy. This is every
    // row of the first sync on a new device.
    if (base == null || local == null) return remote

    return when {
        local.deleted || remote.deleted ->
            // A delete beats an edit. Arguable, and worth saying out loud that
            // it is a product call rather than a technical one.
            remote.copy(deletedAt = maxOf(local.deletedAt, remote.deletedAt))

        else -> remote.copy(
            title = pick(base.title, local.title, remote.title),
            body = pick(base.body, local.body, remote.body),
        )
    }
}

// The whole policy. Only the last branch loses an edit, and naming that case
// honestly is most of the answer. copy on a data class is what keeps the merge
// to the two fields that actually merge, rather than rebuilding the note.
private fun pick(base: String, local: String, remote: String): String = when {
    local == remote -> local    // nobody disagrees
    local == base -> remote     // only the server moved
    remote == base -> local     // only this device moved
    else -> remote              // both moved, the server wins, the local edit is lost
}

com.androidinterview.notes.sync.NoteSync.kt

package com.androidinterview.notes.sync

import com.androidinterview.notes.data.NoteStore
import com.androidinterview.notes.model.Note

data class Page(val notes: List<Note>, val cursor: Long, val hasMore: Boolean)

// Retrofit sits behind this.
interface NoteApi {
    // push carries the revision this edit was based on, so a stale base comes
    // back as the server's current note instead of overwriting it.
    suspend fun push(note: Note, baseRevision: Long): Note
    suspend fun since(cursor: Long): Page
}

// WorkManager runs this with a network constraint in a real app, so it
// survives the process dying and resumes when connectivity returns. Nothing
// here knows that, which is the point of keeping the engine plain.
class NoteSync(private val store: NoteStore, private val api: NoteApi) {

    suspend fun syncOnce() {
        // Push first, so a note typed a second ago reaches the server before a
        // pull can merge over it.
        store.dirty().forEach { local ->
            val base = store.base(local.id)
            val server = api.push(local, base?.revision ?: 0)
            // local is read again after the round trip. A keystroke typed while
            // the push was in flight is then the local side of the merge rather
            // than something the server's copy silently overwrites.
            store.apply(merge(base, store.local(local.id), server))
        }

        // Then pull a page at a time. A new device asking for a hundred
        // thousand notes in one response times out, and a crash halfway
        // through costs one page rather than the whole sync.
        do {
            val page = api.since(store.cursor)
            page.notes.forEach { store.apply(merge(store.base(it.id), store.local(it.id), it)) }
            store.cursor = page.cursor
        } while (page.hasMore)
    }
}

The outbox and backoff shape that carries the push is written out in full under handling data syncing on an unstable network, and the wider read path is in the offline first architecture.

What breaks

  • Two offline edits to the same note. This is the case the interviewer is waiting for. Say what your resolution does and who loses what. Here, different fields both survive, and the same field goes to the server.
  • Deletes. A deleted note has to leave a tombstone, otherwise the next pull from another device happily resurrects it. In this merge a delete beats an edit, which is a product call, so say it is one.
  • Clock skew. Never trust the device clock to order edits. Use a server revision number, or a logical clock, because a phone with the wrong date will otherwise win every conflict.
  • A large attachment on a bad connection. Upload it separately, resumably, and do not let it hold up the note.
  • The first sync on a new device. Pulling a hundred thousand notes in one response is how you get a timeout, so page it and show something useful while it fills in.

Read more Offline first apps (opens in a new tab)

Watch