CQRS: Cross-App Read Models
Apps in Saaslivery never share databases. When App B needs to display data that App A owns, it doesn't query App A's tables — it listens to App A's events and builds its own local read model (a projection). App A commands, App B projects.
This is the CQRS pattern adapted for Saaslivery's modular architecture.
When to use CQRS
Use CQRS when your app needs to display or react to data owned by another app. The pattern applies any time you'd be tempted to query another app's database directly (which is never allowed).
| Situation | Example |
|---|---|
| App B shows a rich card based on App A's data | Chat shows a call card when Calls starts a call |
| App B needs a counter or summary of App A's records | Account shows notification counts from Tasks events |
| App B blocks a date range based on App A's approval | Calendar blocks dates when Vacations approves leave |
If you just need to link to another app's resource, use an MRN (mrn:calls:call:{id}) — no CQRS needed. CQRS is for when you need to store and render the other app's data locally.
The pattern
1. App A does its work and emits an event
The "command" side. App A mutates its own database, then emits an event describing what happened. This is the normal event emission pattern — App A doesn't know or care who's listening.
# apps/calls/apis.py — Calls app creates a call
call_id = generate_id("call")
await db.insert("calls",
id=call_id,
source_mrn=f"mrn:chat:chat:{chat_id}",
status="active",
...)
ctx_emit(ctx, "core.calls.call.started",
call_id=call_id,
join_url=join_url,
source_mrn=f"mrn:chat:chat:{chat_id}",
creator_mrn=member_mrn,
participant_mrns=participant_mrns)
2. App B listens to the event
The "projection" side. App B declares a scan in events.yml and registers a handler route. The event bus delivers the event as an HTTP POST webhook.
events.yml:
scans:
- action: core.calls.call.started
handler: /events/calls-call-started
max_retries: 3
plugin.py:
api.POST("/events/calls-call-started", "apps.chat.apis.on_call_started")
3. App B projects the event into its own schema
The handler receives the event payload, extracts what it needs, and writes to its own tables — never App A's. This local copy is the read model.
async def on_call_started(req: Request, res: Response, ctx: Context):
payload = (req.json or {}).get("payload", {})
source_mrn = payload.get("source_mrn", "")
workspace_id = payload.get("workspace_id")
# Only project calls that originated from chat
if not source_mrn.startswith("mrn:chat:chat:"):
return res.out(HTTPStatus.OK, {"received": True})
chat_id = source_mrn.split(":")[-1]
call_id = payload.get("call_id")
join_url = payload.get("join_url")
creator_mrn = payload.get("creator_mrn")
db = ScopedDB(req.app.peek("db"), workspace_id)
# Project: create a system message in Chat's own messages table
await db.insert("messages",
id=generate_id("msg"),
chat_id=chat_id,
sender_mrn=creator_mrn,
content=f"__call__:{call_id}:{join_url}",
is_deleted=False)
return res.out(HTTPStatus.OK, {"received": True})
4. App B renders from its own data
The UI reads from Chat's messages table — never from Calls' calls table. The template detects the sentinel format and renders a rich card.
{% if msg.content.startswith('__call__:') %}
{% set call_parts = msg.content.split(':', 2) %}
<div class="chat-call-card">
<span class="material-symbols-outlined">video_call</span>
<span>{{ msg.sender.name }} started a call</span>
<a href="{{ call_parts[2] }}" target="_blank">Join call</a>
</div>
{% endif %}
Handling state changes
When App A's resource changes state, it emits another event. App B listens and updates its local projection to match.
# Calls emits core.calls.call.ended
# Chat listens and updates its call card message
async def on_call_ended(req: Request, res: Response, ctx: Context):
payload = (req.json or {}).get("payload", {})
call_id = payload.get("call_id")
duration = payload.get("duration_seconds", 0)
# ...
m = Table("messages")
await (
db.raw.UPDATE(m)
.SET(m.content == f"__call_ended__:{call_id}:{duration}")
.WHERE(m.workspace_id == workspace_id)
.AND(m.chat_id == chat_id)
.AND(m.content.LIKE(f"__call__:{call_id}:%"))
.run()
)
Each state transition is a separate event → separate handler → separate projection update. The local read model stays in sync without ever touching the source database.
Sentinel content format
When projecting data into an existing table (like Chat's messages), use a sentinel prefix so the UI can distinguish projected records from normal ones.
__call__:{call_id}:{join_url} ← active call card
__call_ended__:{call_id}:{duration} ← ended call card
Rules for sentinels:
- Double-underscore prefix and suffix on the type name:
__typename__ - Colon-separated fields after the prefix
- Keep the last field as the "rest" (URLs contain colons — split with a limit)
- Templates detect sentinels with
msg.content.startswith('__call__:')and render accordingly
Triggering via the event bus
Sometimes App B initiates the cross-app flow by requesting an action from App A. App B emits a request event, App A handles it, and then App A emits a result event that App B projects.
Example: Chat requests a call
Chat Event Bus Calls
──── ───────── ─────
│ POST /chats/:id/ │
│ call-requests │
│ │
│── core.chat.call ──────────────────────────→│
│ .requested │
│ Calls creates call + │
│ call_participants │
│ │
│←─────────────────── core.calls.call.started ────│
│ │
│ Chat projects: │
│ insert system msg │
│ broadcast via WS │
Chat never calls the Calls API directly. It emits a request, Calls picks it up, does its work, and emits a result. Chat projects the result. Both apps remain fully decoupled.
Checking if the source app is installed
Since CQRS relies on a soft dependency (enhances, not requires), the source app might not be installed. Before showing UI that depends on the projection, check installation status:
from core.apps import is_app_installed
calls_installed = await is_app_installed(ctx.db.raw, ctx.db.workspace_id, "calls")
Pass this as a template variable and conditionally render:
{% if calls_installed %}
<button @click="requestCall('video')">
<span class="material-symbols-outlined">video_call</span>
</button>
{% endif %}
The event handlers themselves are harmless when the source app isn't installed — they're registered but never fire, because the events are never emitted.
Manifest and events.yml setup
manifest.py — declare the soft dependency and the events:
manifest = {
"enhances": ["core.calls"], # soft — works without it
"events": {
"emits": [
"core.chat.call.requested",
],
"listens": [
"core.calls.call.started",
"core.calls.call.ended",
],
},
}
events.yml — declare the request event schema and the scans:
emits:
- action: core.chat.call.requested
description: A user requested a call from within a chat
schema:
type: object
properties:
chat_id: {type: string}
participant_mrns:
type: array
items: {type: string}
mode: {type: string, enum: [video, audio]}
requested_by: {type: string}
required: [chat_id, participant_mrns, mode, requested_by]
scans:
- action: core.calls.call.started
handler: /events/calls-call-started
max_retries: 3
- action: core.calls.call.ended
handler: /events/calls-call-ended
max_retries: 3
Rules
- Never query another app's tables. The whole point. If you need another app's data, listen to its events.
- Project into your own schema. The read model lives in your tables, under your 10-table budget.
- Handlers must be idempotent. Events are delivered at-least-once. Use upserts or existence checks — don't blindly insert.
- Always scope to workspace_id. The event payload includes it. Your projection must include it. No exceptions.
- Use
enhances, notrequires. CQRS integrations are soft dependencies. Your app must work without the source app installed. Handlers sit idle; UI hides the feature. - Always return 200 from event handlers. Even on error. A non-200 response triggers retries, which you want for transient failures but not for permanent issues (like a missing chat). Log the error, return OK.
- Broadcast after projecting. If the projection affects a live UI (like chat messages), broadcast the change via WebSocket after writing to the database.
- MRNs are the bridge. The source app stores a reference back via MRN (
source_mrn=mrn:chat:chat:{id}). This lets the projecting app know where to project the data. Without a source MRN, there's nothing to project into.
Checklist
When adding a CQRS projection to your app:
- [ ] Source app emits event with
source_mrnpointing to your resource - [ ] Your
manifest.pylists the source app inenhances - [ ] Your
manifest.pylists the events inlistens - [ ] Your
events.ymldeclaresscanswith handler paths and retry config - [ ] Your
plugin.pyregisters POST routes for each event handler - [ ] Handlers create
ScopedDBfrom the event'sworkspace_id - [ ] Handlers are idempotent (safe to replay)
- [ ] UI checks
is_app_installed()before showing dependent features - [ ] Template detects sentinel content format for rich rendering
- [ ] WebSocket broadcast after projection for real-time updates