Skip to content

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.


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.


Different consumers have different needs:

ConsumerNeedsProjection
Table UIReal-time state, low latencyRedis cache
Hand historyComplete replay, searchablePostgres with indexes
Player searchFull-text, fuzzy matchingElasticsearch
AnalyticsAggregations, time seriesData warehouse
Mobile appMinimal payload, denormalizedCustom API cache

One event stream serves all of them. Each projection uses the storage that fits its query pattern.


A projector subscribes to events and updates its storage:

illustrative - projector handler
@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.


ModeUse CaseBehavior
Async (default)Analytics, indexingFire-and-forget, eventually consistent
SyncRead-after-writeCommand 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:

illustrative - sync mode selection
# Default: async, returns immediately
send_command(DepositFunds(amount=1000))
# Sync: framework waits for projectors, returns their results
send_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.


Projections are disposable—in theory. Schema change? Bug fix? Rebuild:

illustrative - projection rebuild
# Clear and rebuild from event history
angzarr projection rebuild --projector=projector-hand-history --from=0

The event store is immutable. The projection is derived. You can always reconstruct.

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.


The framework tracks each projector’s position in the event stream:

illustrative - position tracking
Projector: projector-hand-history
Domain: hand
Last processed: sequence 45,832
Status: caught up

On restart, the projector resumes from its last position. No events missed, no duplicates.


illustrative - hand history projector
@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.


Projectors can subscribe to multiple domains when necessary. Use input_domain on each @handles decorator to specify which domain the handler subscribes to:

illustrative - multi-domain projector
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.


PatternUse CaseImplementation
Live stateTable UIRedis with pub/sub
Audit logComplianceAppend-only Postgres
AnalyticsBusiness intelligenceSnowflake/BigQuery
SearchPlayer lookupElasticsearch
CacheAPI responsesRedis with TTL

Each optimized for its query pattern. All derived from the same events.