Skip to content

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.”

Step through it

Saving one prompt response

Use the controls to follow responsibility across the stack.

Step 1 · Next.js route or Server Action

Intent reaches the server

The browser sends a small intent: prompt ID and response text. It never decides relationship ownership or reveal permission.

Ask while reviewing the codeWhat can an untrusted client lie about?
1 / 7

1. Keep the client request small

The client submits intent, not authority:

ts
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?”

ts
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:

text
(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.

prisma
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.

ts
const response = await transaction.promptResponse.upsert(/* ... */)

await tryQueueNotificationEvent(
  { relationshipId, promptId, type: 'PARTNER_RESPONSES_READY' },
  transaction
)

return response

The 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:

ts
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

  1. Can the client choose an identifier it should only observe?
  2. Is authorization checked at the reusable operation, or only at today's route?
  3. What happens if two copies of this request commit at the same time?
  4. Which effects must roll back together?
  5. Can the returned state explain the next user action without another guess?

Next: data model and invariants.

Built as a living architecture notebook for a personal project.