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
┌──────────── 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
- The local stack running:
auth-service(:40004), the Users pair
(:40171/:40170), the Feed trio (:40167/:40168/:40169), the Messaging trio (:40163/:40164/:40165), and Postgres (the CQRS services' store).
- The tenant key pair in
INAYA_API_KEY/INAYA_APP_KEYand a tenant id in
TENANT_ID. Any tenant id works on the local stack — the verification runs used qa-tenant-t02.
- Run all snippets from the repository root (
/Users/test/projects/Software/InayaLabs). - Polling loops use 5 s intervals; feed entries clear AI moderation at
~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
InayaLabs-website/docs-src/tutorials/_code/social-app-backend/
├── social_app.sh ← the assembled driver (all checkpoints)
└── README.md ← how to run it standaloneDownload 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:
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:
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:
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:
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:
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
- Production access: swap the QA login for the real flows (G-01); point
base URLs at the deployed hosts. The three CQRS pairs scale independently — reads (Query services) can be replicated far beyond writes (Command).
- Realtime instead of polling: the workflow services also emit
Notifications events on publication — swap the poll loops for SSE subscriptions (T-01 Checkpoint 4 shows the pattern).
- Media: feed posts and messages both accept media references — stage
files through the CDN (G-04/G-71) and pass the resulting paths in mediaFiles/mediaUrls.
- Moderation posture: the workflow pipeline is where content policy
lives. A rejected post never reaches the Query side; surface rejection to authors via the Notifications topic pattern from T-01.
Cleanup
# 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
- Build a support assistant (T-01) — the other multi-service composition: Auth → RAG → Evergreen → Notifications.
- Finding matches between profiles (G-31) — turn user profiles into recommendations.
- Uploading files the platform way (G-04) — media for feed posts and DMs.