Skip to main 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.


Common Use Cases

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

Implementation Styles

Angzarr supports two projector implementation styles:

StyleDescriptionBest For
FunctionalStateRouter with function handlersSimple projections, stateless
OOProjector class with @projects/[Projects] decoratorsRich state, encapsulation

Example: Output Projector

Transforms poker events into human-readable text:

StateRouter with explicit event handler registration:

class OutputProjector:
"""
Projector that subscribes to events from all domains and outputs text.

This is a read-side component that:
1. Receives events via saga routing
2. Unpacks them from Any wrappers
3. Renders them as human-readable text
4. Outputs to configured destination (console, file, etc.)
"""

def __init__(
self,
output_fn: Callable[[str], None] = print,
show_timestamps: bool = False,
):
self.renderer = TextRenderer()
self.output_fn = output_fn
self.show_timestamps = show_timestamps

def set_player_name(self, player_root: bytes, name: str):
"""Set display name for a player."""
self.renderer.set_player_name(player_root, name)

def handle_event(self, event_page: types.EventPage) -> None:
"""Handle a single event page from any domain."""
event_any = event_page.event
type_url = event_any.type_url

# Extract event type from type_url
# Format: "type.poker/examples.EventName"
event_type = type_url.split(".")[-1] if "." in type_url else type_url

if event_type not in EVENT_TYPES:
self.output_fn(f"[Unknown event type: {type_url}]")
return

# Unpack the event
event_class = EVENT_TYPES[event_type]
event = event_class()
event_any.Unpack(event)

# Render and output
text = self.renderer.render(event_type, event)
if text:
if self.show_timestamps and event_page.created_at:
from datetime import datetime, timezone

ts = datetime.fromtimestamp(
event_page.created_at.seconds, tz=timezone.utc
)
text = f"[{ts.strftime('%H:%M:%S')}] {text}"
self.output_fn(text)

def handle_event_book(self, event_book: types.EventBook) -> None:
"""Handle all events in an event book."""
for page in event_book.pages:
self.handle_event(page)

def project_from_stream(self, event_stream) -> None:
"""
Project events from a stream (generator or async iterator).

This is the main entry point for saga-routed events.
"""
for event_book in event_stream:
self.handle_event_book(event_book)


Both patterns produce identical behavior—choose based on team preference. The functional StateRouter is more explicit; the OO approach integrates state and handlers in one class.


Multi-Domain Projectors

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

illustrative
# Avoid this pattern when possible
router = StateRouter("prj-output")
.subscribes("player", ["PlayerRegistered", "FundsDeposited"])
.subscribes("table", ["PlayerJoined", "HandStarted"])
.subscribes("hand", ["CardsDealt", "ActionTaken"])

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

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.


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

Angzarr provides built-in projectors for common operational needs:

ProjectorPurposeOutput
LogServiceDebug loggingConsole
EventServiceEvent storageDatabase
OutboundServiceReal-time streaminggRPC
CloudEventsExternal publishingHTTP/Kafka

See Framework Projectors for details.


Next Steps