A request journey
The most useful way to understand a full-stack system is to follow one user intent. This lesson traces “save my answer to today's prompt.”
Saving one prompt response
Use the controls to follow responsibility across the stack.
Intent reaches the server
The browser sends a small intent: prompt ID and response text. It never decides relationship ownership or reveal permission.
1. Keep the client request small
The client submits intent, not authority:
type SaveResponseInput = {
promptId: number
response: string
}It does not submit a trusted userId, partner ID, reveal status, or response scope. Those values come from authenticated server state and database relationships.
Why this matters: TypeScript in the browser improves developer feedback, but it does not make the browser trusted. Any HTTP caller can construct a different payload.
2. Authentication creates a principal
The server turns a WorkOS cookie session or verified bearer token into one user ID. Authentication answers “who is calling?” It does not answer “may this user write to this relationship?”
const user = await requireUser()
const promptId = parsePositiveInt(input.promptId)
await upsertRelationshipPromptResponse({
promptId,
relationshipId,
response: input.response,
userId: user.id,
})The domain operation still checks active membership. That prevents a route refactor or future API client from accidentally bypassing authorization.
3. Route validation and domain validation differ
Boundary validation protects the parser and contract:
- correct HTTP method and content type;
- body below the 16 KiB limit;
- integer prompt ID;
- bounded, non-empty response;
- same-origin request for cookie-authenticated writes.
Domain validation protects business truth:
- the relationship is active;
- the caller is an active member;
- the prompt exists in the allowed lifecycle;
- the response belongs to this relationship scope.
Keeping both avoids two common mistakes: putting business rules in generic schemas, or letting malformed input reach a transaction.
4. Make the transaction own the invariant
The response identity is:
(userId, promptId, responseScopeId)For a couple, PostgreSQL derives responseScopeId as relationship:<id>. A user who later forms a different relationship writes a different row instead of overwriting history.
model PromptResponse {
userId String
promptId Int
relationshipId Int?
responseScopeId String @default("solo")
response String
@@unique([userId, promptId, responseScopeId])
@@index([relationshipId, promptId])
}The unique key makes an upsert retry-safe. The trigger makes scope tampering impossible even if another server path is wrong.
5. Classify side effects
Saving the answer is correctness-critical. Creating an in-app “responses ready” notification is useful but secondary.
const response = await transaction.promptResponse.upsert(/* ... */)
await tryQueueNotificationEvent(
{ relationshipId, promptId, type: 'PARTNER_RESPONSES_READY' },
transaction
)
return responseThe notification helper deduplicates events and records a structured failure without corrupting the answer. If notification delivery ever becomes slow and retry-safe, this is a candidate for the existing queue seam.
6. Return state, not inference
The UI receives an explicit conversation state:
type ConversationProgress =
| 'not_answered'
| 'waiting_for_partner'
| 'ready_to_reveal'
| 'shared'This keeps private reveal rules on the server. The component's job is presentation: render the next understandable action without rediscovering domain truth.
Questions for your own code review
- Can the client choose an identifier it should only observe?
- Is authorization checked at the reusable operation, or only at today's route?
- What happens if two copies of this request commit at the same time?
- Which effects must roll back together?
- Can the returned state explain the next user action without another guess?
Next: data model and invariants.