Home Playground Docs hub

Tutorial T-02 · Multi-service

Build a social app backend

Verified against local · 2026-09-25 · users/feed/messaging command+query+workflow services 0.1.0 (workspace HEAD)
Scenario. Commons is a community app for a neighborhood association.
You will stand up its entire backend in one sitting: two residents sign in,
set up profiles, publish to a shared feed, react and reply, and slide into
DMs — across three CQRS service pairs (Users, Feed, Messaging) with their
workflow services doing moderation and translation asynchronously.

This tutorial composes three guides instead of repeating them: G-30 Managing users & profiles (identity), G-40 Publishing and reading a feed, and G-41 Direct messaging. It adds what those guides can't show alone: one coherent app where the same identities flow through profiles → feed → DMs, and what happens to the app when an asynchronous pipeline dies mid-story.

Time: ~60 min · Level: intermediate

Architecture

text
                 ┌──────────── Auth :40004 ───────────┐
                 │  sign-in mints JWT + user record    │
                 └───────┬───────────────────┬────────┘
                         │                   │
        ┌────────────────▼───────┐   ┌───────▼────────────────┐
        │ Users Command :40171    │   │ Users Query :40170      │
        │  profile writes (PUT)   │   │  reads, search, batch   │
        └───────┬────────────────┘   └────────────────────────┘
                │ one identity, three surfaces
   ┌────────────▼─────────────┐   ┌──────────────────────────────┐
   │ Feed Command :40167       │   │ Messaging Command :40163      │
   │  post / reply / react     │   │  conversations / messages     │
   └───────┬───────────────────┘   └───────┬──────────────────────┘
           │ async (CQRS)                    │ async (CQRS)
   ┌───────▼───────────────────┐   ┌────────▼─────────────────────┐
   │ Feed Workflow :40169       │   │ Messaging Workflow :40165     │
   │  moderation → publish      │   │  moderation → publish         │
   └───────┬───────────────────┘   └────────┬─────────────────────┘
   ┌───────▼───────────────────┐   ┌────────▼─────────────────────┐
   │ Feed Query :40168          │   │ Messaging Query :40164        │
   │  timeline, search          │   │  threads, receipts            │
   └────────────────────────────┘   └──────────────────────────────┘

The shape to internalize: writes are accepted immediately; reads become true only after the workflow publishes. Every "poll until visible" in this tutorial is that contract in action.

Prerequisites

(:40171/:40170), the Feed trio (:40167/:40168/:40169), the Messaging trio (:40163/:40164/:40165), and Postgres (the CQRS services' store).

TENANT_ID. Any tenant id works on the local stack — the verification runs used qa-tenant-t02.

~1–5 minutes each on local hardware (messaging is much faster — the moderation pass is lighter), so the loops allow 3 minutes per feed checkpoint and 5 for the post-restart drain.

PITFALL

Feed and Messaging content is encrypted per tenant: both the command services (on write) and the workflow services (on moderation) fetch the tenant's feed_encryption_key_active / messaging_encryption_key_active from Tenant Admin. A tenant that has never been registered there — or that has no active keys — makes every feed/messaging call fail with a generic 500 (An internal server error occurred). If you create a fresh tenant for this tutorial, provision it first: ``bash # on the tenant-admin public router (:40173), with the tenant key pair # headers, a JWT, and two hex-encoded AES-256 keys (64 hex chars each): curl -X POST "http://127.0.0.1:40173/v3/tenants" \ -H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}" \ -H "Authorization: Bearer ${ADMIN_JWT}" -H "Content-Type: application/json" \ -d "{\"id\": \"${TENANT_ID}\", \"environment_name\": \"local\"}" curl -X PATCH "http://127.0.0.1:40173/v3/tenant-platform-options/tenant/${TENANT_ID}" \ -H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}" \ -H "Authorization: Bearer ${ADMIN_JWT}" -H "Content-Type: application/json" \ -d "{\"feed_encryption_key_active\": \"<64-hex-chars>\", \ \"messaging_encryption_key_active\": \"<64-hex-chars>\"}" ` (Generate keys with openssl rand -hex 32`.) Users/Auth do not need this — only content-encrypting services do — which is why a half-provisioned tenant fails in confusing order: sign-in works, posting 500s.

Companion code

text
InayaLabs-website/docs-src/tutorials/_code/social-app-backend/
├── social_app.sh   ← the assembled driver (all checkpoints)
└── README.md       ← how to run it standalone

Download links: social_app.sh · README.md

Checkpoint 1 — Two residents sign in

Every persona needs a JWT; the login response also carries the user's platform id (user.id) — you will need it to address DMs later:

bash
KEYS=(-H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}")
sign_in() {  # username -> "JWT USERID"
  curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" "${KEYS[@]}" \
    -H "Content-Type: application/json" \
    -d "{\"device_id\": \"t02-device-01\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
    | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["token"], d["user"]["id"])'
}
read PRIYA_T PRIYA_U <<< "$(sign_in t02-priya)"
read OMAR_T  OMAR_U  <<< "$(sign_in t02-omar)"
echo "priya: ${#PRIYA_T} chars, id ${PRIYA_U:0:12}…"
echo "omar:  ${#OMAR_T} chars, id ${OMAR_U:0:12}…"
test -n "${PRIYA_T}" && test -n "${OMAR_T}" && test -n "${OMAR_U}" \
  && echo "checkpoint 1 passed: two identities, two JWTs, omar's id captured"

You should see — checkpoint 1 passed: two identities, two JWTs, omar's id captured. (G-01 covers production sign-in; the QA shortcut stands in here.)

Checkpoint 2 — Profiles: born empty, fleshed out by their owner

Omar's user record was created automatically at first sign-in — read it, modify it locally, and PUT the whole DTO back (the get-modify-put contract, G-30). Then Priya discovers him via user search:

bash
USQ_AUTH=(-H "Authorization: Bearer ${OMAR_T}" "${KEYS[@]}")
curl -sf "${USQ_URL}/v3/users/${OMAR_U}" "${USQ_AUTH[@]}" > /tmp/t02-omar.json

python3 - <<'EOF'
import json
me = json.load(open("/tmp/t02-omar.json"))
me["first_name"] = "Omar"
me["last_name"] = "Haddad"
me["display_name"] = "Omar H."
me["bio"] = "Plot 14 — tomatoes and one very opinionated rooster."
me["location_display_name"] = "North Garden"
me["profile_json"] = {"plot": 14, "skills": ["irrigation", "compost"]}
json.dump(me, open("/tmp/t02-omar-updated.json", "w"))
EOF

curl -sf -o /dev/null -X PUT "${USC_URL}/v3/users/${OMAR_U}" \
  -H "Authorization: Bearer ${OMAR_T}" "${KEYS[@]}" \
  -H "Content-Type: application/json" \
  --data-binary @/tmp/t02-omar-updated.json

curl -sf "${USQ_URL}/v3/users/${OMAR_U}" "${USQ_AUTH[@]}" \
  | python3 -c 'import json,sys; u=json.load(sys.stdin); print("omar is now:", u["display_name"], "|", u["bio"][:40])'

curl -sf -X POST "${USQ_URL}/v3/search" \
  -H "Authorization: Bearer ${PRIYA_T}" "${KEYS[@]}" \
  -H "Content-Type: application/json" \
  -d '{"search_criteria":[{"name":"Omar"}]}' \
  | python3 -c 'import json,sys; r=json.load(sys.stdin); us = r["users"] if isinstance(r, dict) else r; assert any("Omar" in json.dumps(u) for u in us), r; print("checkpoint 2 passed: priya found omar via user search")'

You should see — omar is now: Omar H. | Plot 14 — tomatoes and one very opinionated ro…, then checkpoint 2 passed: priya found omar via user search. Note the search response shape is version-dependent — the snippet accepts either a bare list or a {"users": [...]} envelope; assert on content, not container.

PITFALL

Profile writes are self-only: PUT /v3/users/{id} with another user's JWT returns 401, and the DTO round-trips whole — partial patches are not supported (G-30).

Checkpoint 3 — The feed: post, reply, poll, search

Priya posts to the neighborhood feed; Omar replies. Both writes return ids immediately, but the entries only become readable after the Feed Workflow publishes them — that is the CQRS contract you poll on. Budget real time for the poll: each entry clears an AI moderation pass inside the workflow, which on local hardware takes ~1–5 minutes per entry (small local models, cold caches, per-entry Evergreen sessions). Two entries can therefore take several minutes to surface, and the loops allow 3:

bash
POST_ID=$(curl -s -X POST "${FDC_URL}/v3/feed/post" \
  -H "Authorization: Bearer ${PRIYA_T}" "${KEYS[@]}" \
  -H "Content-Type: application/json" \
  -d '{"content": "Seedling swap this Saturday at the tool shed — bring extras.", "languageCode": "en-US", "userHashtags": ["seedling-swap"], "mediaFiles": []}' \
  | python3 -c 'import json,sys; print(json.load(sys.stdin))')
echo "post id: ${POST_ID}"

REPLY_ID=$(curl -s -X POST "${FDC_URL}/v3/feed/reply" \
  -H "Authorization: Bearer ${OMAR_T}" "${KEYS[@]}" \
  -H "Content-Type: application/json" \
  -d "{\"parentId\": \"${POST_ID}\", \"content\": \"Saving two trays of Roma tomatoes for you.\", \"languageCode\": \"en-US\", \"userHashtags\": [], \"mediaFiles\": []}" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin))')
echo "reply id: ${REPLY_ID}"

SEEN=""
for i in $(seq 1 36); do
  SEEN=$(curl -s "${FDQ_URL}/v3/feed?page=1&page_size=10" \
    -H "Authorization: Bearer ${OMAR_T}" "${KEYS[@]}" \
    | python3 -c 'import json,sys
es = json.load(sys.stdin)["entries"]
print("yes" if any(e["id"] == "'"${POST_ID}"'" for e in es) else "")')
  [ "${SEEN}" = "yes" ] && { echo "published after ${i} poll(s)"; break; }
  sleep 5
done
test "${SEEN}" = "yes" || { echo "post never published"; exit 1; }

curl -s -X POST "${FDQ_URL}/v3/feed/search?page=1&page_size=10" \
  -H "Authorization: Bearer ${OMAR_T}" "${KEYS[@]}" \
  -H "Content-Type: application/json" \
  -d '{"hashtag": "seedling-swap"}' \
  | python3 -c 'import json,sys; es=json.load(sys.stdin)["entries"]; assert any("swap" in e["content"] for e in es), es; print("checkpoint 3 passed: feed round trip (post + reply visible, hashtag search hit)")'

You should see — post and reply ids, published after N poll(s), then checkpoint 3 passed: feed round trip (post + reply visible, hashtag search hit).

Checkpoint 4 — DMs: conversation, message, receipts

Omar slides into Priya's DMs about the tomato trays. Conversation creation is idempotent (same participants + title returns the same conversation), the message lands pending (content: null), and the workflow publishes it seconds later. Receipts close the loop:

bash
CONV_BODY=$(printf '{"otherParticipants": ["%s"], "title": "Seedling swap"}' "${PRIYA_U}")
CID=$(curl -sf -X POST "${MSC_URL}/v3/messaging/conversations" \
  -H "Authorization: Bearer ${OMAR_T}" "${KEYS[@]}" \
  -H "Content-Type: application/json" -d "${CONV_BODY}" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
CID2=$(curl -sf -X POST "${MSC_URL}/v3/messaging/conversations" \
  -H "Authorization: Bearer ${OMAR_T}" "${KEYS[@]}" \
  -H "Content-Type: application/json" -d "${CONV_BODY}" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
test "${CID}" = "${CID2}" && echo "conversation ${CID:0:12}… (idempotent re-create)"

MSG_BODY=$(printf '{"conversationId": "%s", "content": "Could I get three of the Roma trays?", "mediaUrls": []}' "${CID}")
MID=$(curl -sf -X POST "${MSC_URL}/v3/messaging/messages" \
  -H "Authorization: Bearer ${OMAR_T}" "${KEYS[@]}" \
  -H "Content-Type: application/json" -d "${MSG_BODY}" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "message ${MID:0:12}… (pending until the workflow publishes)"

VISIBLE=""
for i in $(seq 1 36); do
  VISIBLE=$(curl -s "${MSQ_URL}/v3/messaging/conversations/${CID}/messages?page=1&page_size=10" \
    -H "Authorization: Bearer ${PRIYA_T}" "${KEYS[@]}" \
    | python3 -c 'import json,sys
ms = json.load(sys.stdin)
m = next((m for m in ms if m["id"] == "'"${MID}"'"), None)
print((m["content"] or "") if m else "")')
  grep -q "Roma trays" <<< "${VISIBLE}" && { echo "published after ${i} poll(s)"; break; }
  sleep 5
done
grep -q "Roma trays" <<< "${VISIBLE}" || { echo "message never published"; exit 1; }

curl -sf -o /dev/null -X POST "${MSC_URL}/v3/messaging/messages/${MID}/delivery-receipts" \
  -H "Authorization: Bearer ${PRIYA_T}" "${KEYS[@]}"
curl -sf -o /dev/null -X POST "${MSC_URL}/v3/messaging/messages/${MID}/read-receipts" \
  -H "Authorization: Bearer ${PRIYA_T}" "${KEYS[@]}"
curl -s "${MSQ_URL}/v3/messaging/messages/${MID}/receipts?page=1&page_size=10" \
  -H "Authorization: Bearer ${OMAR_T}" "${KEYS[@]}" \
  | python3 -c 'import json,sys
rs = json.load(sys.stdin)
assert any(r["status"] == "DeliveredAndRead" for r in rs), rs
print("checkpoint 4 passed: priya read the DM (DeliveredAndRead visible to omar)")'

You should see — the idempotent conversation line, published after N poll(s), then checkpoint 4 passed: priya read the DM (DeliveredAndRead visible to omar).

Checkpoint 5 — Failure injection: kill the feed pipeline mid-story

Now the lesson only a composed tutorial can teach. Kill feed-workflow-service — the moderation/publish pipeline — and watch the write side keep accepting work while the read side goes quiet. Nothing is lost; it is queued behind the dead pipeline. After the restart, give the drain real time: the workflow re-resolves tenant keys and re-runs moderation per queued entry, so on local hardware the stalled post can surface anywhere from ~1 to ~7 minutes after the restart — the poll allows 5 minutes and the final assertion (not the clock) is what guarantees correctness:

bash
pkill -x feed-workflow-service && echo "feed-workflow-service killed"

STALL_ID=$(curl -s -X POST "${FDC_URL}/v3/feed/post" \
  -H "Authorization: Bearer ${PRIYA_T}" "${KEYS[@]}" \
  -H "Content-Type: application/json" \
  -d '{"content": "STALL TEST: rain moved the swap to Sunday.", "languageCode": "en-US", "userHashtags": ["seedling-swap"], "mediaFiles": []}' \
  | python3 -c 'import json,sys; print(json.load(sys.stdin))')
echo "write accepted while pipeline is down: ${STALL_ID}"

STALLED="yes"
for i in $(seq 1 6); do
  FOUND=$(curl -s "${FDQ_URL}/v3/feed?page=1&page_size=20" \
    -H "Authorization: Bearer ${OMAR_T}" "${KEYS[@]}" \
    | python3 -c 'import json,sys
es = json.load(sys.stdin)["entries"]
print("yes" if any(e["id"] == "'"${STALL_ID}"'" for e in es) else "")')
  [ "${FOUND}" = "yes" ] && { STALLED="no"; break; }
  sleep 5
done
test "${STALLED}" = "yes" && echo "checkpoint 5a passed: read side is quiet while the pipeline is down (no error, no data — just lag)"

source /tmp/inaya_env.sh
export INAYA_LABS_ENVIRONMENT=local
nohup /Users/test/projects/Software/InayaLabs/target/debug/feed-workflow-service > /tmp/feed-workflow-service.log 2>&1 &
RECOVERED=""
for i in $(seq 1 60); do
  FOUND=$(curl -s "${FDQ_URL}/v3/feed?page=1&page_size=20" \
    -H "Authorization: Bearer ${OMAR_T}" "${KEYS[@]}" \
    | python3 -c 'import json,sys
es = json.load(sys.stdin)["entries"]
print("yes" if any(e["id"] == "'"${STALL_ID}"'" for e in es) else "")')
  [ "${FOUND}" = "yes" ] && { RECOVERED="yes"; echo "published after restart, poll ${i}"; break; }
  sleep 5
done
test "${RECOVERED}" = "yes" || { echo "stalled post never recovered"; exit 1; }

curl -s "${FDQ_URL}/v3/feed?page=1&page_size=20" \
  -H "Authorization: Bearer ${OMAR_T}" "${KEYS[@]}" \
  | python3 -c 'import json,sys
es = json.load(sys.stdin)["entries"]
assert any("swap to Sunday" in e["content"] for e in es), [e["content"] for e in es]
print("checkpoint 5b passed: queued post drained intact — CQRS lag, not data loss")'

You should see — write accepted while pipeline is down, then checkpoint 5a passed: read side is quiet…, then published after restart, poll N, and checkpoint 5b passed: queued post drained intact — CQRS lag, not data loss.

NOTE

This is the exact failure shape a monitoring alert should key on: write successes without corresponding read-side arrivals. Alert on pipeline lag (oldest unpublished entry age), not on command-service health — the command side stayed healthy through the whole outage.

Ship it

base URLs at the deployed hosts. The three CQRS pairs scale independently — reads (Query services) can be replicated far beyond writes (Command).

Notifications events on publication — swap the poll loops for SSE subscriptions (T-01 Checkpoint 4 shows the pattern).

files through the CDN (G-04/G-71) and pass the resulting paths in mediaFiles/mediaUrls.

lives. A rejected post never reaches the Query side; surface rejection to authors via the Notifications topic pattern from T-01.

Cleanup

bash
# best-effort; these CQRS delete semantics are covered in G-40/G-41
curl -s -o /dev/null -w "post delete: %{http_code}\n" -X DELETE \
  "${FDC_URL}/v3/feed/post/${POST_ID}" -H "Authorization: Bearer ${PRIYA_T}" "${KEYS[@]}"
curl -s -o /dev/null -w "message delete: %{http_code}\n" -X DELETE \
  "${MSC_URL}/v3/messaging/messages/${MID}" -H "Authorization: Bearer ${OMAR_T}" "${KEYS[@]}"

Expected — both 204s (feed delete is idempotent; message repeat-delete returns 4xx — see G-40/G-41 for the exact contracts).

Next journeys