CasesGENERALDOMAIN-SPECIFICSCALE-SPECIFICILLUSTRATIVE

Case: A Chat App

"Users send messages to each other" → Users, Conversation, Messages, Participants → Send, Store, Retrieve, Realtime, Read State. The word "realtime" is the unknown; the decomposition puts it fourth, and the first version works without it.

The moveWorked exampleNext questions

The situation, the reflex, and why it stalls

Every lesson starts where being stuck starts: someone has a problem, and the first move that comes to mind feels like progress.

The question

You are asked for a chat app and the word "realtime" is doing most of the work in the request. How do you decompose it so that realtime is one capability among six rather than the whole problem?

The situation

The brief is "users send messages to each other". Everyone in the room says "so, websockets", and you have never run a websocket in production. You are not sure whether the app is a messaging problem with a realtime feature or a realtime problem with messages in it, and the answer decides what you build first.

The reflex

Research websockets. It is the word everyone said, it is the part you do not know, and a tutorial that pushes a message from one browser tab to another in an afternoon feels like the chat app existing. Persistence, history and read state can be added once messages are moving.

Why it stalls

The tutorial's messages live in memory on one process. Nothing is stored, so the first refresh empties the conversation, and the tutorial has no answer because it never had the requirement.

What the reflex produces — and fails to produce
  • The tutorial's messages live in memory on one process. Nothing is stored, so the first refresh empties the conversation, and the tutorial has no answer because it never had the requirement.
  • Delivery is built before identity is decided. The demo sends to "the other tab"; the product sends to a participant in a conversation, and neither of those nouns exists in the code yet, so the delivery code will be rewritten when they do.
  • Realtime is treated as the core when it is the accelerator. A chat app whose messages arrive on the next poll is a slow chat app; one whose messages are lost is not a chat app. The tutorial optimised the first at the expense of the second.
  • "Read state" is discovered in week three, when it turns out to touch every message, every participant and every device — and the schema built for the tutorial has no room for it.
ProblemUnderstandRequirementsConstraintsUnknownsDecompositionSmallest StepModelExperimentObserveDebugLearnIterate

The move

Precisely enough to apply it to a problem you have never seen — not a slogan.

  • Take the sentence apart into nouns before verbs: Users; a Conversation between some of them; Participants, which is the membership that makes "who can see this?" answerable; Messages, each belonging to one conversation and one sender. Realtime is not a noun and does not appear yet (Entities From Requirements).
  • Decompose by capability, and put the capabilities in dependency order: Send, Store, Retrieve, Realtime Delivery, History, Read State. The first three are the chat app; the fourth makes it feel live; the last two make it usable over time. Each is testable without the ones after it (Decomposition by Capability).
  • Sharpen "realtime" into questions with experiments: how quickly must a message appear on the other side, and what happens to a message sent to someone who is offline? The answers decide the transport, and the transport is a research question with a Networking lesson attached, not a starting point (Researching an Unknown Technology).
  • Make the first slice send → store → retrieve with polling, because it proves the model and the persistence and is the fallback every realtime transport needs anyway. Then replace polling with a push transport as the fourth capability, and measure what changed.

The chat app, by capability

The tree is the §21 expansion of the sentence, with Authentication added because "is this user a participant?" needs a user. Each leaf has the observation that would show it works, and the order of the children is the order of dependency: nothing in Realtime Delivery is testable until Store and Retrieve are.

Notice what is not in the tree: presence, typing, reactions, attachments, search. Each is a later capability and each will slot in as a sibling; none of them needed to exist for the first message to be sent, stored and read.

"Users send messages to each other"
Chat app — V1
  • Authenticationa participant is a user, and a user has to be someone
    • Identify the sendertestable A request without a valid identity cannot post; a request with one is attributed to the right user.
  • Conversationsthe thing a message belongs to and the thing membership is checked against
    • Create a conversation with participantstestable A two-user conversation and a five-user conversation both exist as one Conversation with N Participant rows.
    • Membership checktestable A user who is not a participant cannot read or post; a participant can. One function, called everywhere.
  • Messagesthe product
    • Send and storetestable A posted message exists after a restart, with its sender, conversation and server-assigned time.
    • Retrieve in ordertestable Two messages sent in quick succession are returned in the order the server received them, on every client.
  • Realtime Deliverymakes it feel like chat; built after store because it replays from it
    • Push to connected participantstestable A connected participant sees a new message without acting; a disconnected one sees it on reconnect from history.
  • Historya conversation is read more often than it is written
  • Read Statethe user needs to know what is new
    • Last-read pointer per participanttestable Reading on one device clears the unread count on the other; the count is the number of messages after the pointer.

The visualizer at /thinking/decompose starts from this tree. Try removing Store and watch Realtime Delivery lose its test.

The first slice, with polling

The slice deliberately uses the slowest transport, because the transport is not what the slice is testing. It proves the model, the membership check and persistence; what it does not prove is exactly the list of questions the next capabilities answer.

Send a message, see it on the other side
One participant posts a message and the other participant sees it
  1. ClientPosts the message body to the conversation; polls the conversation every few seconds for new messages after the last one it has.
  2. APIPOST /conversations/{id}/messages checks membership, stores the message, returns it with the server time; GET returns messages after a cursor.
  3. LogicMembership check; server-assigned ordering; nothing else.
  4. StorageMessage rows keyed by conversation and time; Participant rows for membership.
proves
The entities are right, the membership check has one home, messages survive a restart, and ordering is decided by the server.
does not prove
That a message feels instant, that an offline participant will ever receive it, that two devices for one user agree, or that a hundred participants in one conversation is workable. Each is a separate leaf with its own experiment.

"Realtime", sharpened

The word that dominated the first meeting became three questions on the board, each with an experiment small enough to run against the polling slice. The transport was chosen after the experiments, and the choice links out to the lessons that own it rather than being re-taught here.

The chat app after the first slice
known
  • Entities: User, Conversation, Participant, Message. Direct and group are the same model.
  • Membership is the only authorisation question, and it has one function.
  • Messages are stored before they are delivered; delivery replays from the store.
assumed
  • ~One region, one process holding all connections in V1 — written down so the fan-out question is known to be waiting.
  • ~Server time is a good enough order within a conversation. To be revisited if clients need to show their own sends before the server confirms (Optimistic UI).
unknown → question → experiment
  1. ? Realtime.

    becomes How quickly must a message appear on the recipient's screen, and is a client-to-server stream (typing, presence) required, or only server-to-client push?

    experiment Measure the visible delay with polling; swap in server-sent events and measure again; list which requirements need the client to stream, not just receive.

  2. ? Offline users.

    becomes When a participant is disconnected, where does the message wait, and what does the client do on reconnect to catch up without duplicates?

    experiment Disconnect one client, send three messages, reconnect: does "fetch after my last cursor" return exactly three?

  3. ? Two devices.

    becomes If one user has two connected clients, must both receive every message and every read-state change, and which one's "read" wins?

    experiment Two sessions for one user; send from a third; mark read on one; observe the other's unread count.

None of these needed a websocket to be asked. All of them needed to be asked before one was chosen.

How to do it

Most important first.

  • Write the entities and the one relationship that matters: Participant joins a User to a Conversation. Every authorisation question in the app is "is this user a participant?" (Data Modelling From Plain English).
  • Build Send and Retrieve as plain requests against a stored message list, and make the client poll. It is not the product; it is the proof that the model holds.
  • Sharpen the realtime unknowns: latency target, offline behaviour, ordering within a conversation, and whether two devices for one user must both receive. Each becomes a small experiment before a transport is chosen.
  • Choose the transport from the experiments — polling, server-sent events or websockets — and read the Networking lesson on the trade-off before committing (Polling vs Long Polling vs SSE vs WebSockets in Systems covers it).
  • Add History as pagination over stored messages, then Read State as a per-participant pointer into the conversation, and check what "read" means with two devices before writing it (Overwrite or Append?).

Worked on a concrete problem

The move has to produce something. This is what it produced.

  • The sentence, taken apart. "Users send messages to each other" → User, Conversation, Participant, Message. A direct message is a conversation with two participants; a group is one with more — so V1 does not need a separate model for either. Message: id, conversation, sender, body, sent time. Conversation: id, created time. Participant: user, conversation, joined time, and — later — a last-read pointer. Nothing here is a transport.
  • The first slice: a user posts a message to a conversation they are a participant of; it is stored; the other participant's client polls and shows it. It proves: the model, the authorisation check, persistence, ordering by the server's clock. It does not prove: that messages feel instant, that an offline participant receives anything, or that two devices agree. Those are the next three questions, and each has a lesson.
  • The realtime unknown, sharpened. "Realtime" → "how quickly must a message appear, and what happens when the recipient is offline?" Experiment: keep polling, measure the delay a participant sees; then push with server-sent events and measure again. The finding: push cut the visible delay to nearly nothing and changed nothing about offline participants, because they still receive on reconnect from the stored history. The transport is an accelerator; the store is the product. Websockets were chosen over SSE only when typing indicators — a client-to-server stream — appeared as a requirement (WebSockets and Choosing a Real-Time Transport cover the trade-off).
  • Read State, discovered late and correctly. "Mark as read" turned out to mean: per participant, per conversation, the last message they have seen — and with two devices, the later of the two. A per-message read flag was rejected because it multiplies with participants; a pointer per participant is one row and answers "how many unread?" with one comparison. The decision was cheap because the Participant entity already existed to hold it.

How you know it worked

What now exists that did not before, and what question you can now ask.

  • The app works, slowly, with polling — and you can say exactly what the push transport will change and what it will not.
  • Every message operation is guarded by one question — is this user a participant? — and the question has one place to be asked.
  • Realtime, history and read state are separate leaves with separate tests, and none of them required the others to be rewritten.
  • The transport choice can be defended by an experiment and a requirement, not by what everyone said in the first meeting.

The questions you can now ask

The field this whole domain exists for. After this lesson, these are the questions to put to an unfamiliar problem.

Next questions
  • ?Which word in the request is doing the most work, and is it a capability or the whole problem?
  • ?What is the slowest, dumbest transport that would prove the model — and what does the fast transport change that it does not?
  • ?What happens to a message whose recipient is not connected?
  • ?What does "read" mean when one user has two devices?

What can go wrong

How the move itself fails
  • Polling is shipped as the product because it worked and the push transport was "a later feature". A chat app whose messages arrive seconds late loses the requirement that made it a chat app. The polling slice is a proof, not a release.
  • The transport is chosen before the offline question is answered, and the app has no stored history to replay on reconnect, so a dropped connection loses messages. Store came before Realtime in the decomposition for exactly this reason.
  • Realtime is decomposed further than it needs to be — presence, typing, delivery receipts, reactions — before one message has crossed from one user to another.
  • Read state is modelled from the single-device case and rewritten when a user opens a second device. The two-device question is cheap to ask and expensive to discover.
What the move costs
  • Building polling first means building a transport you intend to replace; the work is small but it is visible, and someone will ask why the websocket was not done first.
  • Storing every message before delivering it puts the database on the path of every send. For a chat app that is the correct trade; for a live-cursor feature it would not be.
  • A per-participant read pointer is cheap and answers "unread count" simply, but it cannot say which specific messages were read if reading is not sequential — a requirement the case decided it did not have.
Misreads
  • "So websockets were the wrong choice." They were the right choice in the end, for a stated reason — a bidirectional stream. The case argues against choosing them before the reason existed, not against them.
  • "Polling is fine for chat." It proved the model; it did not meet the latency requirement. The case kept it exactly as long as it answered a question.
  • "Direct messages and groups need different models." A direct message is a two-participant conversation. Modelling them separately doubles every later feature — history, read state, search — for no requirement.

Where this applies

Problem-solving advice is stated as universal far more often than it is. These labels say what each method is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view.

  • GENERALNouns before transports, and store before deliver, hold for any messaging-shaped system — notifications, activity feeds, collaborative editing — though the ordering and delivery guarantees differ by domain.
  • DOMAIN-SPECIFICFor a live-collaboration cursor or a game, the realtime transport is the product and persistence is secondary; the decomposition flips, with delivery first and store as a later capability. The chat app is the case where losing a message is worse than delaying it.
  • SCALE-SPECIFICAt small scale one process holds every connection and delivery is a loop over connected participants. Once connections span several processes, "which server holds this participant's socket?" becomes a routing problem and a fan-out mechanism appears — added when the reading shows one process cannot hold the connections, not before.
  • ILLUSTRATIVEThe room that said "websockets", the polling delay and the typing-indicator requirement are invented; the shape of the decomposition is the point, and no measurements are being reported.

Where the depth lives

This domain asks the question and hands the answer off by name.

Further
  • The decomposition visualizer at /thinking/decompose ships this tree as a preset; the layer-only warning fires if you rebuild it as Frontend / Backend / Database.