Skip to content

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 ​

  1. Local-first. The UI reads only from a local database. Network data is merged into the database, and the UI updates from it.
  2. One sync engine. A single component owns the WebSocket, the REST client, the outbox, and gap-filling. Views never call the network directly.
  3. Server order is truth. Messages are ordered by seq. A message without seq (still pending) is shown after all confirmed messages, in the order it was created.
  4. 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
└──────────────────────┘
ComponentResponsibility
APIClientTyped REST calls generated from or modelled on openapi.yaml. Adds X-Client-Key and Authorization. Retries once after a token refresh on 401.
WebSocketClientURLSessionWebSocketTask. 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.
TokenRefresherSingle-flight refresh: concurrent 401s wait for one refresh call. On refresh failure, log out.
OutboxPersistent queue of pending messages and receipts. Sends in creation order per conversation. Removes an entry only after message.ack.
SyncEngineOn 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 StoreSingle 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
ScreenData sourceKey behaviour
Phone number—E.164 validation, country code picker or "+" prefix.
CodePOST /auth/verify6-digit field, auto-submit on 6th digit.
Profile setupGET /usernames/{username}/availability, PATCH /meDebounced availability check (300 ms).
ChatsLocal store, updated by message.new, conversation.updatedSorted by last activity. Swipe actions: mute, archive. Unread badge on tab.
ConversationLocal store, history via GET .../messages?beforeSeq=Inverted list, sticky date headers, status ticks, typing indicator, offline banner.
FriendsLocal store, updated by friend.* eventsOnline dot, "last seen". Requests badge.
Add friendGET /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: stay PENDING and retry with backoff. Never show an error for these.
  • 4xx (for example 403 NOT_FRIENDS): move to FAILED. Show a retry action and the reason.
  • Groups (A3): delivered when all other members' lastDeliveredSeq ≥ seq; read when all lastReadSeq ≥ seq.

Key flows ​

Send a message ​

  1. Create a row in messages with localStatus = pending, a new clientMessageId, and no seq. Create an outbox entry. The UI shows the message immediately.
  2. If the socket is connected, the outbox sends message.send. Otherwise it waits for the connection.
  3. On message.ack: set id, seq, createdAt, localStatus = confirmed. Delete the outbox entry.
  4. If message.new for the same clientMessageId arrives before the ack, merge it — do not insert a second row.

Receive a message ​

  1. message.new arrives. Upsert by id.
  2. If seq > conversation.lastSeq + 1, there is a gap: call GET /conversations/{id}/messages?afterSeq={lastSeq}.
  3. Send receipt.delivered with the highest contiguous seq.
  4. If the conversation is on screen and the app is active, send receipt.read.

App launch, foreground, and reconnect ​

  1. Load everything from the local store and render immediately.
  2. Refresh the access token if it expires in less than 60 s.
  3. Connect the WebSocket. On session.ready:
    • Send sync.request with { conversationId: lastSeq } for all known conversations.
    • Call GET /conversations?updatedSince= to discover new conversations.
    • Call GET /friend-requests?direction=incoming and GET /friends to refresh the social graph.
  4. Flush the outbox.
  5. When the app goes to the background, close the socket after a short grace period. Push notifications cover the background (A1).

Friend request ​

  1. Search → POST /friend-requests. Insert the outgoing request locally.
  2. The other user receives friend_request.received. Badge on the Friends tab.
  3. They accept → POST /friend-requests/{id}/accept. Both users receive friend_request.accepted with the new friend.
  4. Starting a chat: POST /conversations/direct with the friend's userId. The call is idempotent and returns the existing conversation if there is one.

Testing strategy ​

LevelWhat to testTools
UnitOutbox 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
IntegrationSync engine against recorded API responsesURLProtocol stubs
UISign-up and profile setup, sending a messageXCUITest against the real API with a fresh phone number
ManualThe full test plan on two devicesTestFlight

Pitfalls we often see ​

  • Sorting by createdAt from the device. Device clocks are wrong.
  • Inserting the optimistic message and the echoed message.new as 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.

Questions? Reply to your invitation email.