Skip to content

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.


ProjectorEventsOutput
Search indexerProduct eventsElasticsearch updates
Dashboard streamAll domain eventsWebSocket push
Analytics ETLTransaction eventsData warehouse
Cache warmerPlayer eventsRedis cache
Output rendererPoker eventsConsole text

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.


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.


Projectors can subscribe to events from multiple domains, but should not unless absolutely required:

illustrative
# Avoid this pattern when possible
class 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.


ModeUse CaseBehavior
Async (default)Analytics, search indexingFire-and-forget
SyncRead-after-writeCommand waits for projector

Synchronous projections enable CQRS patterns where commands must return updated read models.


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.


Angzarr provides built-in projectors for common operational needs:

ProjectorPurposeOutput
LogServiceDebug loggingConsole
EventServiceEvent storageDatabase
OutboundServiceReal-time streaminggRPC

See Framework Projectors for details.