Command Query Responsibility Segregation (CQRS) ¶
CQRS separates read operations (queries) from write operations (commands). Instead of a single model serving both purposes, you have two: one optimized for fetching state, one for changing it.
That's it. The idea is simple. How you implement it can be as simple or complex as you want.
The REST Problem ¶
REST HTTP methods seem to cleanly map operations to verbs, but they don't. Real systems break this down immediately:
- Soft deletes: Is it
DELETEorPATCH? - Bulk operations: Is queuing work
POST, or something else? - Long-running processes: Does
POSTvsPUTmatter when it's async? - Complex queries:
GETwith filters, arrays, nested objects - when does this becomePOST?
The method doesn't matter as much as the mental model: Is the user asking for data, or asking me to do something?
Stario's philosophy: stop overthinking it. Use this pattern to get started, discover real patterns as you go, and refactor later when you have concrete constraints.
The Pattern: Persistent Queries + Stateless Commands ¶
Consider the following approach:
- Every user action is a
POSTto a command endpoint. Commands don't return data - they return 204 No Content (this is whatdetached_commandwas built for). - On page load, establish a long-lived query connection (SSE) to
/updatesor similar. - On the backend, wire commands to a queue that notifies the persistent query connection of state changes.
# Command: user does something
@app.detached_command("/add_todo")
async def add_todo(text: Signal[str]):
# Command input is validated and actual execution happens in the background
todos.append({"text": text, "done": False})
notify_state_changed() # Queue handles this
# Query: establish persistent connection
@app.query("/updates")
async def updates_stream():
"""Persistent SSE connection"""
while True:
state_changed.clear()
yield render_todos(todos) # Send complete state
await state_changed.wait()
Data Flow:
- User clicks button →
POST /add_todo - Command (segregated responsibility): validates command input, returns
204immediately. - User's browser continues (doesn't wait)
- Command modifies state and notifies the queue.
- Queue wakes up the
/updatesconnection and sends the new state to the browser. - Query (segregated responsibility): reads complete state, renders HTML
- Query yields new HTML through SSE
- Browser receives and displays update
Notice: Commands never return state. Queries never modify state. The queue is the only bridge. That's all there is to segregation.
When a command finishes, it doesn't need to return the new state. The command just notifies the queue. The persistent query connection sees that notification, re-renders, and streams the new state back to the user.
Why This Works ¶
No request/response mismatch: Commands are fire-and-forget (204). State updates arrive through the persistent connection - the only channel specifically designed for streaming updates.
Multiplayer for free: All connected clients have the same SSE stream open. When state changes, everyone sees it immediately. No sync logic needed.
Rendering is centralized: The render function receives complete state and generates complete HTML. When the UI is wrong, you know exactly where to fix it.
Technical Notes ¶
These operations are remarkably cheap and simple:
- HTTP/2 or HTTP/3 makes multiple concurrent connections and streaming efficient.
- SSE connections are essentially free - they're just long-lived HTTP responses.
- Brotli compression compresses SSE streams exceptionally well. Repeated HTML structures compress dramatically.
- No proxy headaches: Unlike WebSockets, SSE is just HTTP. Proxies, load balancers, and CDNs all understand it. No special configuration needed.
You get WebSocket-like interactivity without the operational complexity or network restrictions. The tradeoff is inherent to the model: the server sends updates, the client receives them. No true bidirectional streams. For most applications, this is exactly what you want.
