Appearance
Recommended App Architecture
This page describes an architecture that meets every requirement of the challenge. It is a recommendation, not a rule. If you choose differently, explain why in your README.
Principles
- Local-first. The UI reads only from a local database. Network data is merged into the database, and the UI updates from it.
- One sync engine. A single component owns the WebSocket, the REST client, the outbox, and gap-filling. Views never call the network directly.
- Server order is truth. Messages are ordered by
seq. A message withoutseq(still pending) is shown after all confirmed messages, in the order it was created. - Every send is idempotent. A
clientMessageId(UUID) is created once, stored with the pending message, and reused on every retry.
Layers
┌──────────────────────────────────────────────────────────────┐
│ SwiftUI Views │
│ Onboarding · ChatsList · Conversation · Friends · Settings │
└───────────────▲──────────────────────────────────────────────┘
│ observe state / send intents
┌───────────────┴──────────────────────────────────────────────┐
│ View Models (@Observable, @MainActor) │
└───────────────▲──────────────────────────────────────────────┘
│ async calls / AsyncSequence of changes
┌───────────────┴──────────────────────────────────────────────┐
│ Repositories │
│ AuthRepository · ProfileRepository · FriendsRepository │
│ ConversationsRepository · MessagesRepository │
└──────▲─────────────────────────────────────────▲─────────────┘
│ read/write │ commands
┌──────┴───────────────┐ ┌────────────────┴─────────────┐
│ Local Store │◀───────│ Sync Engine (actor) │
│ SwiftData / GRDB │ merge │ WebSocketClient │
│ (users, friends, │ │ APIClient (REST, async) │
│ conversations, │ │ Outbox processor │
│ messages, outbox) │ │ Gap-fill / catch-up │
└──────────────────────┘ │ Token refresher │
└──────────────▲───────────────┘
┌──────────────────────┐ │
│ Keychain │◀──────────────────────┘ tokens
└──────────────────────┘| Component | Responsibility |
|---|---|
APIClient | Typed REST calls generated from or modelled on openapi.yaml. Adds X-Client-Key and Authorization. Retries once after a token refresh on 401. |
WebSocketClient | URLSessionWebSocketTask. Connects, sends ping every 25 s, reconnects with jittered exponential backoff (1 s, 2 s, 4 s … max 30 s). Exposes incoming events as an AsyncStream. |
TokenRefresher | Single-flight refresh: concurrent 401s wait for one refresh call. On refresh failure, log out. |
Outbox | Persistent queue of pending messages and receipts. Sends in creation order per conversation. Removes an entry only after message.ack. |
SyncEngine | On connect: sends sync.request with the last seq for every conversation. Detects seq gaps and fetches missing messages. Applies events to the local store. |
Local Store | Single source of truth for the UI. Unique constraints: messages.id, messages.clientMessageId. |
Screens and navigation
Launch
├─ not signed in ──▶ Welcome ─▶ Phone number ─▶ Code (000000) ─┐
│ │
├─ signed in, profile incomplete ◀──────────────────────────────┤
│ └─▶ Profile setup (display name, username, avatar) │
│ │
└─ signed in, profile complete ◀───────────────────────────────┘
└─▶ TabView
├─ Chats ───────▶ Conversation ─▶ Contact info (block, report)
│ └─ (A3) Group info
├─ Friends ─────▶ Add friend (search)
│ │ ▶ Requests (incoming / outgoing)
│ └────────▶ Friend profile ─▶ Message
└─ Settings ────▶ Edit profile
▶ Privacy (last seen)
▶ Blocked users
▶ Log out
▶ Delete account| Screen | Data source | Key behaviour |
|---|---|---|
| Phone number | — | E.164 validation, country code picker or "+" prefix. |
| Code | POST /auth/verify | 6-digit field, auto-submit on 6th digit. |
| Profile setup | GET /usernames/{username}/availability, PATCH /me | Debounced availability check (300 ms). |
| Chats | Local store, updated by message.new, conversation.updated | Sorted by last activity. Swipe actions: mute, archive. Unread badge on tab. |
| Conversation | Local store, history via GET .../messages?beforeSeq= | Inverted list, sticky date headers, status ticks, typing indicator, offline banner. |
| Friends | Local store, updated by friend.* events | Online dot, "last seen". Requests badge. |
| Add friend | GET /users/search?q= | Button state follows friendshipStatus. |
Local data model
users(id PK, displayName, username, about, avatarUrl, lastSeenAt, isOnline, isDeleted)
friendships(userId PK, since)
friendRequests(id PK, fromUserId, toUserId, status, createdAt)
conversations(id PK, type, title, avatarUrl, lastSeq, lastReadSeq, unreadCount,
mutedUntil, archived, updatedAt)
members(conversationId, userId, role, lastDeliveredSeq, lastReadSeq) PK(conversationId, userId)
messages(id UNIQUE NULL, clientMessageId UNIQUE, conversationId, seq NULL, senderId,
type, body, mediaId, replyToId, editedAt, deletedAt, createdAt, localStatus)
outbox(clientMessageId PK, conversationId, payloadJSON, attempts, nextAttemptAt, lastError)
syncState(key PK, value) -- e.g. "conversationsUpdatedSince"localStatus is used only for your own outgoing messages: pending, failed, or confirmed. Delivered and read are derived from the other members' watermarks — see below.
Message status state machine
create ack received
(draft) ─────────▶ PENDING ───────────────────▶ SENT ✓
│ ▲ │ other member's lastDeliveredSeq ≥ seq
4xx error │ │ user taps retry ▼
(not retryable) ▼ │ DELIVERED ✓✓
FAILED ⚠ │ other member's lastReadSeq ≥ seq
▼
READ ✓✓ (blue)- Network errors and
5xx: stayPENDINGand retry with backoff. Never show an error for these. 4xx(for example403 NOT_FRIENDS): move toFAILED. Show a retry action and the reason.- Groups (A3): delivered when all other members'
lastDeliveredSeq ≥ seq; read when alllastReadSeq ≥ seq.
Key flows
Send a message
- Create a row in
messageswithlocalStatus = pending, a newclientMessageId, and noseq. Create anoutboxentry. The UI shows the message immediately. - If the socket is connected, the outbox sends
message.send. Otherwise it waits for the connection. - On
message.ack: setid,seq,createdAt,localStatus = confirmed. Delete theoutboxentry. - If
message.newfor the sameclientMessageIdarrives before the ack, merge it — do not insert a second row.
Receive a message
message.newarrives. Upsert byid.- If
seq > conversation.lastSeq + 1, there is a gap: callGET /conversations/{id}/messages?afterSeq={lastSeq}. - Send
receipt.deliveredwith the highest contiguousseq. - If the conversation is on screen and the app is active, send
receipt.read.
App launch, foreground, and reconnect
- Load everything from the local store and render immediately.
- Refresh the access token if it expires in less than 60 s.
- Connect the WebSocket. On
session.ready:- Send
sync.requestwith{ conversationId: lastSeq }for all known conversations. - Call
GET /conversations?updatedSince=to discover new conversations. - Call
GET /friend-requests?direction=incomingandGET /friendsto refresh the social graph.
- Send
- Flush the outbox.
- When the app goes to the background, close the socket after a short grace period. Push notifications cover the background (A1).
Friend request
- Search →
POST /friend-requests. Insert the outgoing request locally. - The other user receives
friend_request.received. Badge on the Friends tab. - They accept →
POST /friend-requests/{id}/accept. Both users receivefriend_request.acceptedwith the new friend. - Starting a chat:
POST /conversations/directwith the friend'suserId. The call is idempotent and returns the existing conversation if there is one.
Testing strategy
| Level | What to test | Tools |
|---|---|---|
| Unit | Outbox ordering and retry, gap detection, status derivation, token single-flight refresh, event merge (ack before or after message.new) | Swift Testing / XCTest, a fake APIClient and fake socket |
| Integration | Sync engine against recorded API responses | URLProtocol stubs |
| UI | Sign-up and profile setup, sending a message | XCUITest against the real API with a fresh phone number |
| Manual | The full test plan on two devices | TestFlight |
Pitfalls we often see
- Sorting by
createdAtfrom the device. Device clocks are wrong. - Inserting the optimistic message and the echoed
message.newas two rows. - Sending read receipts while the app is in the background.
- Reconnecting in a tight loop without backoff, draining the battery.
- Re-rendering the whole message list on every typing event.
- Losing the outbox when the app is killed because it was kept only in memory.