Query-Optimized Projections
The table UI needs millisecond updates. The analytics team needs hand histories in a data warehouse. The mobile app needs a different shape entirely.
One event stream. Many projections. Each optimized for its purpose.
The Concept
Section titled “The Concept”A projection is a query-optimized view built from events. It’s the “read side” of CQRS—derived data structured for fast retrieval, not for recording history.
flowchart LR
subgraph events["Events (source of truth)"]
e1[HandStarted]
e2[PlayerActed]
e3[PotAwarded]
end
subgraph projectors["Projectors"]
h[Handlers]
end
subgraph projections["Projections (query-optimized)"]
p1["Redis: live table state"]
p2["Postgres: hand history"]
p3["Elastic: player search"]
end
events --> projectors
projectors --> projections
The event store is the source of truth. Projections are disposable views—rebuild any of them by replaying events.
Why Multiple Projections?
Section titled “Why Multiple Projections?”Different consumers have different needs:
| Consumer | Needs | Projection |
|---|---|---|
| Table UI | Real-time state, low latency | Redis cache |
| Hand history | Complete replay, searchable | Postgres with indexes |
| Player search | Full-text, fuzzy matching | Elasticsearch |
| Analytics | Aggregations, time series | Data warehouse |
| Mobile app | Minimal payload, denormalized | Custom API cache |
One event stream serves all of them. Each projection uses the storage that fits its query pattern.
Projector Handlers
Section titled “Projector Handlers”A projector subscribes to events and updates its storage:
@projector("table-state")class TableStateProjector: def __init__(self, redis: Redis): self.redis = redis
@handles("PlayerSeated") def on_player_seated(self, event: PlayerSeated): self.redis.hset( f"table:{event.table_id}", f"seat:{event.seat}", json.dumps({"player_id": event.player_id, "stack": event.stack}) )
@handles("PlayerActed") def on_player_acted(self, event: PlayerActed): self.redis.hset( f"table:{event.table_id}", "last_action", json.dumps({"player": event.player_id, "action": event.action_type}) )The projector receives events, transforms them, and writes to its target storage. No business logic—just data transformation.
Sync vs Async
Section titled “Sync vs Async”| Mode | Use Case | Behavior |
|---|---|---|
| Async (default) | Analytics, indexing | Fire-and-forget, eventually consistent |
| Sync | Read-after-write | Command waits for projectors to complete |
Projectors just project—they don’t know or care about sync mode. The caller specifies sync mode when sending commands:
# Default: async, returns immediatelysend_command(DepositFunds(amount=1000))
# Sync: framework waits for projectors, returns their resultssend_command( DepositFunds(amount=1000), sync_mode=SyncMode.SYNC_MODE_SIMPLE,)The framework decides whether to return projection results to the caller. The projector code is identical either way.
Rebuilding Projections
Section titled “Rebuilding Projections”Projections are disposable—in theory. Schema change? Bug fix? Rebuild:
# Clear and rebuild from event historyangzarr projection rebuild --projector=projector-hand-history --from=0The event store is immutable. The projection is derived. You can always reconstruct.
A Note on “Disposable”
Section titled “A Note on “Disposable””Small projections rebuild in seconds. Large projections—years of transaction history, millions of events—can take hours and cost real money in compute and storage I/O.
Architects should plan accordingly:
- Retention policies: Do you need every event forever, or can older data age out?
- Incremental rebuilds: Can you rebuild from a checkpoint rather than from zero?
- Backup projections: For critical read models, consider snapshotting the projection itself
- Cost modeling: Estimate rebuild time and cost before assuming “we’ll just rebuild”
For large projections, it may be cheaper to maintain and migrate them than to rebuild from scratch. “Disposable” means you can rebuild, not that you should.
Position Tracking
Section titled “Position Tracking”The framework tracks each projector’s position in the event stream:
Projector: projector-hand-historyDomain: handLast processed: sequence 45,832Status: caught upOn restart, the projector resumes from its last position. No events missed, no duplicates.
Example: Hand History for Poker
Section titled “Example: Hand History for Poker”@domain("hand")class HandHistoryProjector(Projector): name = "projector-hand-history"
def __init__(self, store: HandHistoryStore): self.store = store
@handles("HandStarted") def handle_hand_started(self, event: HandStarted): self.store.open_hand(event.hand_id, event.table_id, event.timestamp, event.blinds)
@handles("PlayerActed") def handle_player_acted(self, event: PlayerActed): self.store.record_action(event.hand_id, event.player_id, event.action, event.amount, event.seq)
@handles("HandComplete") def handle_hand_complete(self, event: HandComplete): self.store.close_hand(event.hand_id, event.winner_id, event.pot, event.timestamp)The read model is a plain domain object — HandHistoryStore exposes business-oriented methods (recent_hands_for(player_id), actions_in(hand_id)) and hides the storage backend. Projectors translate events into calls on that interface; callers query it through the same interface.
Multi-Domain Projectors
Section titled “Multi-Domain Projectors”Projectors can subscribe to multiple domains when necessary. Use input_domain on each @handles decorator to specify which domain the handler subscribes to:
class LeaderboardProjector(Projector): name = "projector-leaderboard"
@handles(PlayerRegistered, input_domain="player") def on_player_registered(self, event: PlayerRegistered): self.create_player_entry(event.player_id)
@handles(HandComplete, input_domain="hand") def on_hand_complete(self, event: HandComplete): self.update_player_stats(event.winner_id, event.pot)Use sparingly. Multi-domain projectors often indicate a domain boundary issue.
Projection Patterns for Gaming
Section titled “Projection Patterns for Gaming”| Pattern | Use Case | Implementation |
|---|---|---|
| Live state | Table UI | Redis with pub/sub |
| Audit log | Compliance | Append-only Postgres |
| Analytics | Business intelligence | Snowflake/BigQuery |
| Search | Player lookup | Elasticsearch |
| Cache | API responses | Redis with TTL |
Each optimized for its query pattern. All derived from the same events.
See Also
Section titled “See Also”- Projector component — Implementation details
- Performance — Scaling projections