Projectors
A projector subscribes to events and performs side effects—typically building read models, writing to databases, or streaming to external systems. Projectors transform the event stream into query-optimized views.
Unlike aggregates and sagas, projectors are read-only from the event sourcing perspective. They observe events but never emit commands back to aggregates.
Common Use Cases
Section titled “Common Use Cases”| Projector | Events | Output |
|---|---|---|
| Search indexer | Product events | Elasticsearch updates |
| Dashboard stream | All domain events | WebSocket push |
| Analytics ETL | Transaction events | Data warehouse |
| Cache warmer | Player events | Redis cache |
| Output renderer | Poker events | Console text |
Canonical Style
Section titled “Canonical Style”Projectors are defined as classes extending the client library’s Projector base (or the language’s closest equivalent). Handler methods are registered by decorator / attribute / macro and share the instance’s state.
Example: Output Projector
Section titled “Example: Output Projector”Transforms poker events into human-readable text:
@projector(name="prj-output", domains=["player", "table", "hand"])class OutputProjector: """Output projector using OO-style decorators with multi-domain support."""
@handles(player.PlayerRegistered) def project_player_registered(self, event: player.PlayerRegistered) -> None: write_log(f"PLAYER registered: {event.display_name} ({event.email})")
@handles(player.FundsDeposited) def project_funds_deposited(self, event: player.FundsDeposited) -> None: amount = event.amount.amount if event.HasField("amount") else 0 new_balance = event.new_balance.amount if event.HasField("new_balance") else 0 write_log(f"PLAYER deposited {amount}, balance: {new_balance}")
@handles(table.TableCreated) def project_table_created(self, event: table.TableCreated) -> None: write_log(f"TABLE created: {event.table_name} ({event.game_variant})")
@handles(table.PlayerJoined) def project_player_joined(self, event: table.PlayerJoined) -> None: player_id = truncate_id(event.player_root) write_log(f"TABLE player {player_id} joined with {event.stack} chips")
@handles(table.HandStarted) def project_hand_started(self, event: table.HandStarted) -> None: write_log( f"TABLE hand #{event.hand_number} started, " f"{len(event.active_players)} players, dealer at position {event.dealer_position}" )
@handles(hand.CardsDealt) def project_cards_dealt(self, event: hand.CardsDealt) -> None: write_log(f"HAND cards dealt to {len(event.player_cards)} players")
@handles(hand.BlindPosted) def project_blind_posted(self, event: hand.BlindPosted) -> None: player_id = truncate_id(event.player_root) write_log( f"HAND player {player_id} posted {event.blind_type} blind: {event.amount}" )
@handles(hand.ActionTaken) def project_action_taken(self, event: hand.ActionTaken) -> None: player_id = truncate_id(event.player_root) write_log(f"HAND player {player_id}: {event.action} {event.amount}")
@handles(hand.PotAwarded) def project_pot_awarded(self, event: hand.PotAwarded) -> None: winners = [ f"{truncate_id(w.player_root)} wins {w.amount}" for w in event.winners ] write_log(f"HAND pot awarded: {', '.join(winners)}")
@handles(hand.HandComplete) def project_hand_complete(self, event: hand.HandComplete) -> None: write_log(f"HAND #{event.hand_number} complete")One class per projector: state lives on the instance, handlers call into instance methods.
Multi-Domain Projectors
Section titled “Multi-Domain Projectors”Projectors can subscribe to events from multiple domains, but should not unless absolutely required:
# Avoid this pattern when possibleclass OutputProjector(Projector): domains = ["player", "table", "hand"]
@projects(PlayerRegistered) def on_player_registered(self, event): ...
@projects(HandStarted) def on_hand_started(self, event): ...If you need multi-domain subscription, check your domain boundaries first. Needing to join events across domains often indicates the domains are incorrectly partitioned. Consider whether those events belong in the same domain.
When multi-domain is truly necessary (e.g., a cross-cutting analytics view), it’s technically safe because projectors only observe—but prefer single-domain projectors where possible for simpler reasoning and deployment.
Synchronous vs Asynchronous
Section titled “Synchronous vs Asynchronous”| Mode | Use Case | Behavior |
|---|---|---|
| Async (default) | Analytics, search indexing | Fire-and-forget |
| Sync | Read-after-write | Command waits for projector |
Synchronous projections enable CQRS patterns where commands must return updated read models.
Position Tracking
Section titled “Position Tracking”Projectors track their position in the event stream to enable:
- Catch-up: Resume from last processed event after restart
- Replay: Rebuild read models from scratch
The framework manages position tracking automatically.
Framework Projectors
Section titled “Framework Projectors”Angzarr provides built-in projectors for common operational needs:
| Projector | Purpose | Output |
|---|---|---|
| LogService | Debug logging | Console |
| EventService | Event storage | Database |
| OutboundService | Real-time streaming | gRPC |
See Framework Projectors for details.
Next Steps
Section titled “Next Steps”- Framework Projectors — Built-in operational projectors
- Process Managers — Stateful multi-domain coordination
- Testing — Testing projectors with Gherkin