Part IIIMobile System DesignChapter 03 of 1650 min

03 · Chat client

"Design the client side of a chat app."

A recurring senior mobile prompt. The answer stays small: a database the screen observes, one repository per source, a read-only composite, one socket and a background sender.

Interviewer: "Let's design the client side of a chat app. One-to-one and small group conversations. Where would you start?"

Run it as a round, not as reading

Set a timer. Sketch. Talk the whole time. Open a section only when its slot is over.

ClockPhaseWhat you do
0:00–0:02The promptRepeat it back in one sentence
0:02–0:07ClarifyAsk yours, then read section 1
0:07–0:12ScopeOut of scope first, then required, then later
0:12–0:24High levelThe idea · the parts · the sketch · one line each · two flows
0:24–0:40Deep divesThe interviewer picks one of sections 9–11
0:40–0:45RecapFollow-ups, then the 60-second summary

What this question is really testing

  • Who decides the order? The server's sequence number, not the phone's clock.
  • A send that doesn't wait, and a retry the server recognises: a client message ID.
  • The screen observes a database on the phone. The network only writes into it.
  • Catching up after a gap, because a socket can miss things.
  • Honest splits: the socket, the sending loop and each source are separate parts.

Traps: ordering by the phone's clock; a send that waits for the server; a retry with a new ID; one "read up to" for a group of 50; trusting push; "not read" when receipts failed.

Words used in this chapter
  • Repository — the one owner of one kind of data. It turns the server's format into the app's model.
  • Composite — a repository that only reads: it asks two repositories and combines their answers into one model.
  • Store — in-memory state that several screens show at once. Not needed here yet.
  • Use case — one user intent with a product rule, like "a read marker only moves forward".
  • View model — the screen's brain. It owns the screen's state and hands the screen one view state.
  • WebSocket — a line kept open, so the server can say "new message" at once.
  • Sequence number — the server's counter per conversation: 417, 418, 419. It is the order.
  • Client message ID — an ID the sender's device gives a message. Sent twice, the server keeps one.
  • Outbox — messages saved on the phone that the server hasn't accepted yet.
  • Read position — how far one member has read: "read up to 417". A group has one per member.
1 · The interviewer answers your clarifying questions

"One-to-one only, or groups?" → Both. Groups up to about 50 people.

"What's in a message?" → Text only.

"Offline?" → Yes. Read with no signal; write a message that goes out later.

"Several devices?" → A phone and an iPad. Both show the same order.

"What does a sent message show?" → Sending, sent, delivered, read. Failed, with a retry.

"Who has read what?" → A receipts endpoint, one read position per member.

"Which screens?" → The conversation screen. The conversation list comes later.

"How many users?" → 20 million a day, 30 messages each. Don't design the backend.

"Minimum OS?" → iOS 17, SwiftUI, Swift 6.

2 · Requirements and scope — what you should have said

Out of scope, said first: the server, attachments, typing, calls, editing, search, encryption, sign-in, the conversation list.

Required for the prompt

  1. Open a conversation instantly, even offline.
  2. Send online or offline: it shows at once, is kept once, and shows its status.
  3. Receive new messages live, and catch up on anything missed.
  4. Scroll back through older messages.

What must feel good: no lost or doubled messages · the same order on every device · never waiting on the network · ticks that never lie.

Production considerations (say, don't draw): token refresh, timeouts, safe retries, metrics. See section 12.

Optional follow-ups (add only when asked):

The conversation list      → a second reader of the same database
Typing indicators          → socket events, kept in memory
New messages while closed  → push to nudge, catch-up to deliver
"Read by 12 of 49"         → fetched when the user asks
Sending after hours closed → a background refresh

"I'll start simple and add each of these when the requirement appears."

3 · The idea, in 30 seconds — before you draw anything

"The screen observes a database on the phone, so it opens instantly and works offline. A messages repository owns messages: GET /conversations/{id}/messages for history and catch-up, POST /conversations/{id}/messages to deliver. A receipts repository owns read positions: GET /conversations/{id}/receipts and POST /conversations/{id}/read. One live connection holds WSS /live while the app is open and hands each event to its owner. Sending saves first, with an ID the phone chose; an outbox sender delivers unsent rows oldest first, with the same ID on retry. A read-only composite combines messages and positions into one Message with its ticks, and marking read is a use case, because the read marker only moves forward."

4 · What we need — the components, before the sketch
#PartTypeLayerIts one job
1Conversation screenConversationViewPresentationDraws the view state, forwards actions.
2Conversation view modelConversationViewModelPresentationMessages and history → one view state.
3Mark-read use caseForwardOnlyMarkReadUseCaseDomainThe read marker only moves forward.
4Composite conversation repositoryCompositeConversationRepositoryDataMessages + read positions → Message.
5Messages repositoryLocalFirstMessagesRepositoryDataOwns messages.
6Receipts repositoryLocalFirstReceiptsRepositoryDataOwns read positions.
7Outbox senderOutboxSenderDataDelivers unsent rows, oldest first.
8Live connectionLiveConnectionDataOne socket while open; routes events.
9Message databaseSQLiteMessageDatabaseDataRows on disk, observed.
chipConversation repositoryConversationRepositoryDomainThe read promise card 4 keeps.
chipMessages repositoryMessagesRepositoryDomainThe send promise card 5 keeps.
chipReceipts repositoryReceiptsRepositoryDomainThe mark-read promise card 6 keeps.

Nine cards, three chips. Message is written on the composite's card.

No use case for sending or loading: same-ID retries and oldest-first draining are delivery mechanics, and loading has no rule.

Not drawn: APIClient (HTTP, auth) for cards 5 and 6, WebSocketClient over URLSessionWebSocketTask for card 8, and NetworkMonitor (protocol NetworkStatus) over NWPathMonitor for card 7. Not on the list yet: everything under "optional follow-ups".

5 · The sketch
---
config:
  layout: elk
---
flowchart TB
  subgraph P["PRESENTATION"]
    View["`**1 · Conversation screen**
ConversationView`"] -- "actions → view state" --> VM["`**2 · Conversation view model**
ConversationViewModel`"]
  end
  subgraph D["DOMAIN"]
    Mark["`**3 · Mark-read use case**
ForwardOnlyMarkReadUseCase`"]
    Conv["`«protocol»
ConversationRepository`"]
    MsgP["`«protocol»
MessagesRepository`"]
    RcpP["`«protocol»
ReceiptsRepository`"]
  end
  subgraph DA["DATA"]
    Comp["`**4 · Composite conversation repository**
CompositeConversationRepository
messages + positions → Message`"]
    Out["`**7 · Outbox sender**
OutboxSender`"]
    Live["`**8 · Live connection**
LiveConnection
WSS /live`"]
    Msgs["`**5 · Messages repository**
LocalFirstMessagesRepository
GET /conversations/{id}/messages
POST /conversations/{id}/messages`"]
    Rcps["`**6 · Receipts repository**
LocalFirstReceiptsRepository
GET /conversations/{id}/receipts
POST /conversations/{id}/read`"]
    DB["`**9 · Message database**
SQLiteMessageDatabase`"]
  end
  VM -- "send → saved" --> MsgP
  MsgP ~~~ Msgs
  Msgs -. "implements" .-> MsgP
  MsgP ~~~ Out
  VM -- "watch → messages" --> Conv
  Conv ~~~ Comp
  Comp -. "implements" .-> Conv
  VM -- "mark → done" --> Mark
  Mark ~~~ Conv
  Mark ~~~ MsgP
  Mark -- "send → position" --> RcpP
  RcpP ~~~ Rcps
  Rcps -. "implements" .-> RcpP
  RcpP ~~~ Live
  Comp -- "watch → messages" --> Msgs
  Comp -- "watch → positions" --> Rcps
  Out -- "next → delivered" --> Msgs
  Live -- "message events" --> Msgs
  Live -- "receipt events" --> Rcps
  Msgs -- "save → rows" --> DB
  Rcps -- "save → positions" --> DB
  class View,VM pres
  class Mark,Conv,MsgP,RcpP dom
  class Comp,Out,Msgs,Rcps,DB data
  class Live net

How to read it. Solid arrows: request → reply, asker to answerer. Dashed: implements. Orange is the network connection.

Dependency inversion, in one line. The domain writes the three promises (the chips); cards 4, 5 and 6 keep them, so the dashed arrows point up and the view model never sees a socket, a database or HTTP.

One owner per source. Messages reach the database only through card 5, positions only through card 6. The socket and the sending loop have their own lifecycles, so they are cards 7 and 8.

No shared store on this sketch: one screen shows the conversation. When the list arrives, the database is what both screens observe.

On Miro: three frames, nine cards, three chips. Point at card 9: "the screen reads only this; the network only writes."

6 · Each component: its job, its interface, the choice inside it

The model everyone shares. The screen never sees a DTO or a row.

struct Message: Identifiable, Hashable, Sendable {
    /// the client message ID, chosen by the sender's device
    let id: MessageID
    let senderID: UserID
    let text: String
    let sentAt: Date
    /// the server's order, nil until accepted
    let sequence: Int?
    /// pending, sent, failed: my message reaching the server
    var send: SendState
    /// unknown, sent, delivered, read: how far the others got
    var receipt: ReceiptState
}

The ID comes from the sender's device, so a bubble keeps its identity from "sending" to "read".

1 · Conversation screen (ConversationView)

Job: draws the view state and reports actions and scrolling. It decides nothing.

Interface: reads viewModel.viewState; calls start() from .task, send(_:), retry(_:), topReached(), newestVisible(_:).

Choice: a ScrollView with a LazyVStack, defaultScrollAnchor(.bottom) to open at the newest message, and scrollPosition(id:) so prepending history keeps the reader on the same bubble. Switch to a wrapped UICollectionView if it stutters: a lazy stack doesn't reuse rows.

2 · Conversation view model (ConversationViewModel)

Job: observes one conversation and turns it, plus history paging, into one view state.

Interface:

struct ConversationViewState: Equatable {
    let title: String
    let rows: [MessageRow]
    /// idle, loading, failed(message), beginning
    let history: HistoryState
}

struct MessageRow: Equatable, Identifiable {
    let id: MessageID
    let text: String
    let isMine: Bool
    /// already formatted, e.g. "14:02"
    let time: String
    /// clock, retry, one tick, two, read, dimmed. nil if not mine
    let ticks: Ticks?
}

@MainActor @Observable
final class ConversationViewModel {
    init(conversation: ConversationID,
         reader: ConversationRepository,
         messages: MessagesRepository,
         markRead: MarkReadUseCase)

    var viewState: ConversationViewState { get }

    /// Runs until the view's task is cancelled
    func start() async
    func send(_ text: String) async
    func retry(_ id: MessageID) async
    func topReached() async
    func newestVisible(_ id: MessageID)
}

Choices:

  • No optimistic overlay. A pending message is a real saved row, so its bubble comes from the database. Nothing to roll back; a rejection turns only that bubble red.
  • History is this screen's state, guarded so a second topReached() returns while one load is out.

3 · Mark-read use case (ForwardOnlyMarkReadUseCase)

Job: moves my read marker forward, one request per conversation at a time, always the highest position seen.

protocol MarkReadUseCase: Sendable {
    /// Never throws. Ignores positions at or below the highest
    /// wanted. Not cancelled by the caller's task.
    func markRead(upTo sequence: Int, in conversation: ConversationID) async
}

actor ForwardOnlyMarkReadUseCase: MarkReadUseCase {
    init(receipts: ReceiptsRepository)
}

Choices:

  • The rule a product manager would state: "the read marker never moves back". That's why it's a use case and sending isn't.
  • One app-wide actor, so every caller shares its "highest wanted" per conversation.
  • Serialized, latest wins. While a request is out, newer positions only raise the wanted number; when it returns, send again if higher. A failure waits for the next call, which covers it.

4 · Composite conversation repository (CompositeConversationRepository)

Job: combines messages with members' read positions into Messages with ticks. It owns no source and never writes.

protocol ConversationRepository: Sendable {
    /// Replays the current value, one stream per caller, ends
    /// when the caller's task is cancelled. Never fails: ticks
    /// stay .unknown until positions load once.
    func messages(in conversation: ConversationID) -> AsyncStream<[Message]>
}

final class CompositeConversationRepository: ConversationRepository {
    init(messages: MessageStream, positions: ReadPositionStream)
}

How it combines, each time either stream emits:

  1. Take the latest messages and the latest positions.
  2. No positions yet → ticks stay unknown.
  3. For each of my sent messages, look at the other members: all read past it → read; all delivered → delivered; else sent.
  4. Sort by sequence; pending ones last, oldest first.

Choices:

  • The trade-off, said out loud: receipts cost a second request on open. In production I'd ask for positions in the catch-up reply; nothing above the domain changes.

5 · Messages repository (LocalFirstMessagesRepository)

Job: the one owner of messages: saves pending ones, fetches history and catch-up, delivers on request, applies socket events.

/// Domain: the view model's promise
protocol MessagesRepository: Sendable {
    /// Saves a pending row with a new client ID
    func send(_ text: String, in conversation: ConversationID) async throws
    /// Failed → pending again, same ID, back of the queue
    func retry(_ id: MessageID) async throws
    /// GET ?before=, false at the beginning. Cancellable.
    func loadOlder(in conversation: ConversationID) async throws -> Bool
}

/// Data-internal: the composite's. Opening it catches up.
protocol MessageStream: Sendable {
    func messages(in conversation: ConversationID) -> AsyncStream<[Message]>
}

/// Data-internal: the outbox sender's
protocol PendingMessages: Sendable {
    func unsentChanged() -> AsyncStream<Void>
    func oldestUnsent() async throws -> MessageID?
    /// POST and save. Throws .temporary or .rejected (row failed)
    func deliver(_ id: MessageID) async throws(DeliveryError)
}

/// Data-internal: the live connection's
protocol MessageEventSink: Sendable {
    func apply(_ event: MessageEventDTO) async
    /// Catches up every conversation being observed
    func reconnected() async
}

final class LocalFirstMessagesRepository: MessagesRepository,
    MessageStream, PendingMessages, MessageEventSink {
    init(api: APIClient, database: MessageDatabase)
}

Choices:

  • One class, four small promises (interface segregation): each caller sees only its part, and each fake needs a method or two.
  • Saved by client ID, and a row only moves forward (pending → sent, never back). That is the stale-result guard, carried by the ID and the sequence: a late catch-up can't undo a newer event.
  • It runs no loop. Cards 7 and 8 call it.

6 · Receipts repository (LocalFirstReceiptsRepository)

Job: the one owner of read positions: fetches them, posts mine, applies socket receipt events.

/// Domain: the use case's promise
protocol ReceiptsRepository: Sendable {
    /// Returns the server's position. Throws if refused or
    /// unreachable. Cancelling the caller cancels it.
    func markRead(upTo sequence: Int, in conversation: ConversationID) async throws -> Int
}

/// Data-internal: the composite's. nil until one fetch works.
protocol ReadPositionStream: Sendable {
    func positions(in conversation: ConversationID) -> AsyncStream<ReadPositions?>
}

/// Data-internal: the live connection's
protocol ReceiptEventSink: Sendable {
    func apply(_ event: ReceiptEventDTO) async
    func reconnected() async
}

final class LocalFirstReceiptsRepository: ReceiptsRepository,
    ReadPositionStream, ReceiptEventSink {
    init(api: APIClient, database: MessageDatabase)
}

Choice: a position is saved as the higher of old and new, so a late event can't move a tick back.

7 · Outbox sender (OutboxSender)

Job: delivers unsent messages, oldest first, one at a time.

actor OutboxSender {
    init(pending: PendingMessages, network: NetworkStatus)
    /// Called once at launch
    func start()
    func stop()
}

Choices:

  • Triggers: launch, the network coming back, a new unsent row.
  • One at a time. In parallel, the server could accept them in any order.
  • Temporary error → back off; rejected → move on. It owns no endpoint: card 5 delivers.

8 · Live connection (LiveConnection)

Job: holds one WebSocket while in the foreground and hands each event to its owner.

actor LiveConnection {
    init(socket: WebSocketClient,
         messages: MessageEventSink,
         receipts: ReceiptEventSink)
    /// The app root calls these from scenePhase
    func start()
    func stop()
}

Choices:

  • Foreground only. A closed app gets push; the catch-up delivers.
  • A dead socket can look open. sendPing every 30 seconds finds out; then reconnect with backoff and jitter, and call reconnected() on both sinks.
  • Auth: a short-lived token at connect; an expired one is refreshed, then it reconnects.

9 · Message database (SQLiteMessageDatabase)

Job: keeps rows and positions on disk, and re-sends a query's result after every write.

protocol MessageDatabase: Sendable {
    /// Re-sent after each committed write
    func messageRows(in conversation: ConversationID) -> AsyncStream<[MessageRecord]>
    func positionRows(in conversation: ConversationID) -> AsyncStream<[PositionRecord]>
    /// Insert or update by client ID, in one transaction
    func save(_ rows: [MessageRecord]) async throws
    func save(_ positions: [PositionRecord]) async throws
}

Choices:

  • SQLite with GRDB's ValueObservation, which re-runs a query after each committed write. On Core Data, NSFetchedResultsController does the same.
  • Rejected on iOS 17: SwiftData behind a view model. @Query observes only inside a view, and fetchHistory is iOS 18.
  • Records only. File protection on, deleted at sign-out.

Why these parts, and no more. Each card changes for one reason:

CardChanges when
1 · Conversation screenthe screen's design
2 · Conversation view modelthe screen's behaviour
3 · Mark-read use casea product rule (how the marker moves)
4 · Compositehow two sources combine
5 · Messages repositoryan endpoint or payload (/messages)
6 · Receipts repositoryan endpoint or payload (/receipts, /read)
7 · Outbox sendera background job's lifecycle (triggers, backoff)
8 · Live connectiona connection's lifecycle
9 · Message databasea storage schema

Each protocol earns a fake or a smaller promise. Not added: a sending or loading use case, a view model protocol, a shared store, a DI container.

7 · Key flows through the sketch

Flow 1 — opening a conversation.

sequenceDiagram
  participant VM as View model
  participant C as Composite
  participant M as Messages repo
  participant R as Receipts repo
  participant DB as Database
  VM->>C: messages(in: c)
  C->>M: messages(in: c)
  C->>R: positions(in: c)
  M->>DB: observe rows
  DB-->>M: saved rows, at once
  M-->>C: Messages
  R-->>C: saved positions or nil
  C-->>VM: Messages with ticks
  Note over M: GET ?after= newest saved
  M-->>C: new Messages after save
  C-->>VM: updated list

Flow 2 — sending in a tunnel.

sequenceDiagram
  participant VM as View model
  participant M as Messages repo
  participant DB as Database
  participant O as Outbox sender
  participant S as Server
  VM->>M: send("On my way")
  M->>DB: save pending, id 9F2C
  Note over VM: the stream shows a clock
  M-->>O: unsent changed
  O->>M: deliver(9F2C)
  M->>S: POST /messages, id 9F2C
  alt accepted
    S-->>M: 201, sequence 418
    M->>DB: save sent, 418
    M-->>O: delivered
  else timeout or 5xx
    M-->>O: temporary
    Note over O: back off, same ID later
  else rejected
    S-->>M: 4xx
    M->>DB: save failed
    M-->>O: rejected, move on
  end

Flow 3 — receiving live, then catching up.

sequenceDiagram
  participant S as Server
  participant L as Live connection
  participant M as Messages repo
  participant DB as Database
  S-->>L: message 419
  L->>M: apply(event)
  M->>DB: save by ID
  Note over S,L: socket drops in a tunnel
  L->>S: reconnect, backoff and jitter
  L->>M: reconnected()
  M->>S: GET ?after=419
  S-->>M: 420, 421
  M->>DB: save 2 by ID
8 · The server contract
GET  /v1/conversations/{id}/messages?before={sequence}&limit=50
GET  /v1/conversations/{id}/messages?after={sequence}&limit=50
     → { "items": [ { "clientMessageId", "senderId", "text",
                      "sequence", "sentAt" } ],
         "hasMore": true }

POST /v1/conversations/{id}/messages
     { "clientMessageId": "9F2C…", "text": "On my way" }
     → 201 { "clientMessageId", "sequence": 418, "sentAt" }
       (same clientMessageId again → 200, the same message)

GET  /v1/conversations/{id}/receipts
     → { "members": [ { "userId", "deliveredUpTo": 418,
                        "readUpTo": 417 } ] }

POST /v1/conversations/{id}/read   { "upToSequence": 419 }
     → 200 { "readUpTo": 419 }       // never lower than before

WSS  /v1/live                       // token at connect
     ← { "type": "message", "message": { … } }
     ← { "type": "receipt", "conversationId", "userId",
         "deliveredUpTo", "readUpTo" }
  • Sequence numbers per conversation, promised in order with no holes, so a jump shows a gap.
  • clientMessageId makes sending idempotent, if the server remembers IDs longer than the client retries.
  • Positions per member, because a group's "read" must say whose. A second request on open; the ?after= reply could carry them.
9 · Deep dive — "Messages arrive out of order, twice, or not at all."

Decision: the sequence is the order, the client ID removes duplicates, a catch-up fills gaps.

  1. Save by client ID. A message from the socket and again from the catch-up shows once. So does my own message from the iPad: it arrives with the iPad's client ID.
  2. Sort by sequence. A late message lands in the right place.
  3. Spot a gap. 418 then 421 → GET ?after=418, relying on the no-holes promise.
  4. Catch up after every reconnect, from the newest saved sequence. A socket doesn't promise delivery across a drop.

The pending bubble that moves. A pending message sits at the bottom. Accepted, it takes its sequence, usually the newest, so it stays put. If another message got in first, it moves above mine once, animated: the order every device shows.

Rejected: ordering by the sender's clock. Clocks drift, so two devices would disagree.

10 · Deep dive — "I send a message in a tunnel, then close the app."

Decision: save first, show at once, deliver later with the same ID.

  1. Save the row as pending. The database stream puts it on screen at once.
  2. The outbox sender wakes on the new row, the network coming back, or the next launch, and delivers oldest first, one at a time.
  3. Temporary error (timeout, 5xx, 429 with Retry-After) → still pending, back off. Rejected (4xx) → only that message fails; the queue moves on.
  4. Every retry posts the same clientMessageId. If only the reply was lost, the server answers "already have it".

beginBackgroundTask asks for a little time after closing; the system can end it early, and the rest goes at next launch.

Rejected: disabling send offline. Safe, but it feels broken.

11 · Deep dive — "Read receipts in a group of 50, and a second screen."

Marking read.

  1. The newest message is on screen, app active: the view model calls markRead(upTo: 419).
  2. The use case drops anything not above the highest wanted; one request per conversation at a time.
  3. The server keeps the higher number and sends the others a receipt event.
  4. Their receipts repositories save the higher position; the composite recomputes ticks.

Group ticks. "Read" means every other member's readUpTo is at or past the message: the lowest of 49 numbers. One shared number can't say whose.

Receipts fail to load. Keep saved positions; with none, ticks are unknown, dimmed. Rejected: "not delivered", maybe false.

The conversation list is a second composite over the same repositories. One save updates both screens, because both observe the database. No shared store, no change bus.

flowchart TB
  CVM["`**View model**
ConversationViewModel`"] -- "watch → messages" --> Comp["`**Composite**
CompositeConversationRepository`"]
  LVM["`**View model**
ConversationListViewModel`"] -- "watch → summaries" --> List["`**Composite**
CompositeConversationListRepository`"]
  Comp -- "watch → rows" --> Msgs["`**Messages repository**
LocalFirstMessagesRepository`"]
  List -- "watch → newest" --> Msgs
  Msgs -- "observe → rows" --> DB["`**Message database**
SQLiteMessageDatabase`"]
  class CVM,LVM pres
  class Comp,List,Msgs,DB data
12 · Failure modes, production and scale

Failures

FailureClient behaviour
OfflineOpen from the database; sends wait
Send times out or 5xxStill pending; back off, same ID
Send rejected (4xx)Only that message turns failed, tap to retry
Socket drops or goes silentPing finds it; reconnect with jitter, catch up
Receipts call failsKeep saved ticks; none saved → dimmed, never "not delivered"
App killed mid-sendNext launch delivers it, same ID
Token expiredRefresh, retry once; socket reconnects
Signed outSocket closed, database deleted

Production notes (short, not on the board)

  • Networking: a timeout per request; retry only what's safe (timeouts, 5xx, and sends, thanks to the ID); honour Retry-After on 429; cancel history reads for a closed screen, never a delivery.
  • Background: push nudges a closed app, and a UNNotificationServiceExtension can adjust its text; delivery is still the catch-up on open.
  • Metrics: send-to-sent time, outbox size, reconnects, time to open. Never message text.

Scale, back of the envelope, with the interviewer's numbers:

daily users × messages each  = messages a day
messages a day ÷ 86,400 s    = sends per second (average)

20M × 30 is 600 million a day, about 7,000 a second. That's the server's cost, not the client's shape.

Grow only when asked

The conversation list      → a second composite, same database
New messages while closed  → push to nudge, catch-up to deliver
"Read by 12 of 49"         → the same positions, shown on request
Sending after hours closed → a background refresh step
13 · Question bank — everything they can push on

Answer out loud first, then open.

Scope and architecture

"Walk me through the layers."

Presentation: screen, view model. Domain: Message, three protocols, the mark-read use case. Data: two repositories, a read-only composite, the sender, the connection, the database.

"Why does marking read get a use case and sending doesn't?"

"The marker never moves back" is a product rule. Same-ID retries and oldest-first draining are delivery mechanics, so they live with the data they deliver.

"Why is the socket its own part?"

Its lifecycle (foreground, ping, reconnect, token) changes for other reasons than any endpoint. Inside a repository it would be a second job.

"Show me dependency inversion."

The domain owns the three protocols; the data cards implement them, so the view model never imports networking or SQLite.

"Where does mapping happen?"

Each repository maps its DTOs to records on save, and records to app models on read. The composite only combines app models.

"Where does the combined state come from?"

The composite, each time messages or positions change. No store: one screen, and a second would observe the same database.

"Why does the messages repository have four protocols?"

Four callers need different parts: send, read, deliver, apply events. One class, four small promises.

Ordering and the API

"Why not order by timestamp?"

Phone clocks drift and can be changed. The server's sequence is the same on every device.

"What guarantees order and no holes?"

The server's promise. The client still saves by ID and catches up after gaps.

"How does the UI find out the database changed?"

The database re-runs the query after each write and emits rows; the view model iterates the stream from the view's .task.

"Why not SwiftData?"

On iOS 17 it observes only through @Query in a view; history is iOS 18. Behind a view model I need a stream.

Follow-up chain: "The reply is lost."
  1. "You post and it times out." → The row stays pending.
  2. "Did the server get it?" → You can't know. Retry with the same ID.
  3. "It had." → It returns the stored message. One message, not two.
  4. "Retry with a new ID?" → Your friend gets it twice.

Sending

"What happens when I tap send?"

A pending row is saved, so the bubble appears at once. The sender sees it and delivers.

"Three messages queued offline. In what order do they go?"

One at a time, oldest first. In parallel the server could accept them in any order.

"The second of three is rejected."

Only it turns failed; the third still goes. A tapped retry keeps its ID and gets a later sequence.

"Is the 'sending' bubble optimistic UI state?"

No, it's a real saved row: the outbox. Nothing to roll back, so neither the view model nor a repository holds an overlay.

Receiving and receipts

"The socket was down for five minutes."

On reconnect, GET ?after= the newest saved sequence, saved by ID.

"I send from my iPad. What does my phone show?"

It arrives by socket with the iPad's client ID, saved as mine. A catch-up lands on the same row.

"What does 'read' mean in a group of 50?"

Every other member has read past it: the lowest of their positions. "Read by 12" comes from the same numbers.

"The receipts call fails. Show 'not read'?"

No. Keep saved ticks; with none, they're unknown and dimmed.

Follow-up chain: "A second screen."
  1. "Add the conversation list. A message arrives." → The repository saves it.
  2. "How does the list update?" → Its composite observes the same repositories.
  3. "No shared store?" → The database is what both screens observe.
  4. "When would you add a store?" → For in-memory state both show, like a typing indicator.

Testing and change

"How do you test sending?"

The sender with a fake PendingMessages that fails once, then succeeds: two deliveries, same ID.

"How do you test group ticks?"

The composite with two fake streams: three messages, positions for three members. Check the lowest position decides "read".

"What changes at 10×?"

Mostly the server. On the client: positions in the catch-up reply, less history kept, backing off on 429.

Follow-up chain: "Two weeks instead of six."
  1. "What do you cut?" → Older-history paging and group detail.
  2. "What can't you cut?" → The database as the screen's source, client IDs, catch-up after reconnect.
  3. "Risk?" → Long conversations start shorter. Fixable next release.
14 · Scorecard — mark yourself, 0 / 1 / 2
Did you…0–2
1Talk the whole time, and listen when interrupted
2Clarify before designing
3Give a trade-off for each choice
4Say out of scope first, including the server
5Say the idea, list the parts, then sketch
6Keep the sketch to nine type cards, one line each
7Name the five endpoints and the socket
8Order by the server's sequence, not the clock
9Reuse the client ID on retry; fail only that message
10Make the screen observe the database, and say how
11Split the socket and the sender from the repositories
12Keep the composite read-only; map in each repository
13Group "read" per member; failed receipts unknown
14Land the recap inside 60 seconds

14+ is a pass.A zero names the thing to read about next.:::

15 · The 60-second recap

The screen observes a SQLite database on the phone, so it opens instantly and works offline; the network only writes into it. A messages repository and a receipts repository each own one source, and a read-only composite combines messages and per-member read positions into one Message with ticks. Sending saves a pending row with an ID the phone chose; an app-scoped outbox sender delivers unsent rows oldest first and retries with the same ID, so the server keeps each message once. One live connection holds the socket while open, routes events, and triggers a catch-up after every reconnect. The sequence is the order everywhere. Marking read is the one use case: the marker only moves forward.

Sources for this chapter

weeeBox · Mobile system design exercise, Chat applicationhttps://github.com/weeeBox/mobile-system-design/blob/master/exercises/chat-app.mdtechinterview.org · Chat delivery, ordering, presencehttps://www.techinterview.org/post/3233476407/chat-system-design-delivery-ordering-presence/Apple · URLSessionWebSocketTaskhttps://developer.apple.com/documentation/foundation/urlsessionwebsockettaskApple · sendPing(pongReceiveHandler:)https://developer.apple.com/documentation/foundation/urlsessionwebsockettask/sendping(pongreceivehandler:)Apple · ScenePhasehttps://developer.apple.com/documentation/swiftui/scenephaseApple · NWPathMonitorhttps://developer.apple.com/documentation/network/nwpathmonitorApple · beginBackgroundTask(withName:expirationHandler:)https://developer.apple.com/documentation/uikit/uiapplication/beginbackgroundtask(withname:expirationhandler:)Apple · UNNotificationServiceExtensionhttps://developer.apple.com/documentation/usernotifications/unnotificationserviceextensionApple · LazyVStackhttps://developer.apple.com/documentation/swiftui/lazyvstackApple · scrollPosition(id:anchor:)https://developer.apple.com/documentation/swiftui/view/scrollposition(id:anchor:)Apple · defaultScrollAnchor(_:)https://developer.apple.com/documentation/swiftui/view/defaultscrollanchor(_:)Apple · NSFetchedResultsControllerhttps://developer.apple.com/documentation/coredata/nsfetchedresultscontrollerApple · SwiftData Queryhttps://developer.apple.com/documentation/swiftdata/queryApple · ModelContext.fetchHistory(_:) (iOS 18)https://developer.apple.com/documentation/swiftdata/modelcontext/fetchhistory(_:)GRDB · ValueObservationhttps://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/valueobservation