Skip to content

Architecture

This document covers ⍼ Angzarr’s core architectural concepts: event sourcing data model, coordinator pattern, deployment model, and synchronization modes.


⍼ Angzarr stores aggregate history as an EventBook—the complete event stream for a single aggregate root:

ComponentPurpose
CoverIdentity: domain, aggregate root ID, correlation ID
SnapshotPoint-in-time state for replay optimization
EventPagesOrdered sequence of domain events
message Cover {
string domain = 2;
UUID root = 1;
string correlation_id = 3; // Workflow correlation - flows through all commands/events
Edition edition = 4; // Edition for diverged timelines; empty name = main timeline
// Field 5 removed: external_id moved to ExternalDeferredSequence in PageHeader
// Client-supplied extension slot. Most consumers pack a nested
// ``Cover`` here to express parent-aggregate relationships (e.g.
// table events carry the tournament Cover; hand events carry the
// table Cover). Helper ``unpack_parent_cover`` extracts the typed
// Cover when present. Framework code treats this as opaque routing
// metadata — it propagates without inspecting.
//
// WARNING — mass-propagating: the framework stamps this slot onto
// EVERY event a child aggregate emits, so the payload is paid per
// event. Keep what you pack small. The intended workload is a single
// nested Cover (~50–80 bytes packed); larger payloads multiply
// across the event stream and the bus.
google.protobuf.Any ext = 6;
}
message EventPage {
PageHeader header = 1; // Sequence type and provenance
google.protobuf.Timestamp created_at = 2;
oneof payload {
google.protobuf.Any event = 3;
PayloadReference external = 4; // Claim check: payload stored externally
}
// Field 5 removed: FactSequence replaced by PageHeader.external_deferred
// Two-phase commit support (Phase 1: 2PC Storage Model)
bool no_commit = 6; // true = pending 2PC (cascade), needs Confirmation; false (default) = immediately committed
optional string cascade_id = 7; // Groups related pending events for atomic commit/rollback (null if not in cascade)
}
// Snapshot of aggregate state at a given sequence number.
// State must be a protobuf Message to serialize into Any.
message Snapshot {
uint32 sequence = 2;
google.protobuf.Any state = 3;
SnapshotRetention retention = 4; // Controls cleanup behavior
// Wall-clock timestamp the snapshot was persisted. Used by
// temporal-by-time queries to decide whether the snapshot's
// event coverage predates the target timestamp (snapshot is safe
// to use iff created_at <= target). Absent on snapshots persisted
// before this field existed; readers MUST treat absent as
// "do not use this snapshot for temporal-by-time" (safe full-replay
// fallback).
google.protobuf.Timestamp created_at = 5;
}
message EventBook {
Cover cover = 1;
Snapshot snapshot = 2; // Snapshot state; sequence computed by framework on persist
repeated EventPage pages = 3;
// Field 4 removed: correlation_id moved to Cover
// Field 5 removed: snapshot_state unified into snapshot field
uint32 next_sequence = 6; // Computed on load, never stored: (last page seq OR snapshot seq if no pages) + 1
}

Commands follow the same pattern—a CommandBook contains one or more CommandPages targeting a single aggregate.


⍼ Angzarr uses coordinators to route messages between external clients and your business logic. This separation keeps domain code focused while the framework handles:

  • Event persistence and retrieval
  • Optimistic concurrency via sequence numbers
  • Snapshot management
  • Event upcasting (schema evolution on read)
  • Synchronous vs. asynchronous processing
CoordinatorRoutesPurpose
CommandHandlerCoordinatorCommands → AggregatesCommand handling, event persistence
ProjectorCoordinatorEvents → ProjectorsRead model updates, side effects
SagaCoordinatorEvents → SagasCross-domain command orchestration
ProcessManagerCoordinatorEvents → PMsStateful multi-domain workflows
flowchart TB
    Client[External Client]
    CHC[CommandHandlerCoordinator]
    Agg[Your Aggregate]
    ES[(Event Store)]
    BUS[Message Bus]
    SC[SagaCoordinator]
    PC[ProjectorCoordinator]
    Saga[Your Saga]
    Proj[Your Projector]
    RM[(Read Model<br/>Postgres, Redis)]

    Client -->|gRPC| CHC
    CHC <-->|gRPC| Agg
    CHC -->|SQL| ES
    CHC -->|AMQP/Kafka| BUS
    BUS -->|AMQP/Kafka| SC
    BUS -->|AMQP/Kafka| PC
    SC <-->|gRPC| Saga
    SC -->|gRPC| CHC
    PC <-->|gRPC| Proj
    Proj -->|SQL| RM

⍼ Angzarr runs as a sidecar container alongside your business logic. Each pod contains your code and the appropriate coordinator, communicating over localhost gRPC.

flowchart TB
    subgraph AggPod[Aggregate Pod]
        subgraph AggCode[Your Code]
            Agg[Aggregate Logic]
            Upc[Upcaster Logic]
        end
        AggSidecar[⍼ Command Handler Coordinator]
        AggCode <-->|gRPC| AggSidecar
    end

    subgraph SagaPod[Saga Pod]
        Saga[Saga Logic]
        SagaSidecar[⍼ Saga Coordinator]
        Saga <-->|gRPC| SagaSidecar
    end

    subgraph PrjPod[Projector Pod]
        Prj[Projector Logic]
        PrjSidecar[⍼ Projector Coordinator]
        Prj <-->|gRPC| PrjSidecar
    end

    AggSidecar -->|SQL| ES[(Event Store)]
    AggSidecar -->|AMQP/Kafka| MB[Message Bus]
    MB -->|AMQP/Kafka| SagaSidecar
    MB -->|AMQP/Kafka| PrjSidecar
    SagaSidecar -->|gRPC| AggSidecar
    PrjSidecar -.->|gRPC| AggSidecar
    Prj -->|SQL| PrjDB[(Projection DB)]
  • Minimal attack surface: ~8MB distroless container, no shell, no package manager
  • No network exposure: Sidecar communicates over localhost only
  • Horizontal scaling: Follows your service scaling—no separate capacity planning
  • Local gRPC: Eliminates network latency between logic and framework

⍼ Angzarr provides mechanisms for controlling sync vs async communication — when results return to callers.

// Controls synchronous processing behavior.
//
// Impact ordering (lowest to highest):
// ASYNC < DECISION < SIMPLE < CASCADE
//
// Primary caller of DECISION is process managers coordinating cross-aggregate
// flows: the PM issues a command to the target aggregate and needs to know
// *only* whether the aggregate accepted or rejected, without paying the cost
// of projector propagation or saga fan-out. The aggregate's accept path
// (events persisted + returned) and reject path (CommandRejectedError) are
// both observable; everything downstream runs asynchronously.
enum SyncMode {
SYNC_MODE_ASYNC = 0; // Async: fire and forget (default)
SYNC_MODE_SIMPLE = 1; // Sync projectors only, no saga cascade
SYNC_MODE_CASCADE = 2; // Full sync: projectors + saga cascade (expensive)
SYNC_MODE_DECISION = 3; // Sync aggregate accept/reject only; projectors + sagas run async
SYNC_MODE_ISOLATED = 4; // Sync accept/reject + persist; NO downstream (sync OR async). Replay / migration / recovery writes that must not trigger reactions. Distinct from DECISION (which still publishes to bus for async downstream).
}
ModeProjectorsSagasUse Case
NONEAsyncAsyncFire-and-forget, eventual consistency
SIMPLESyncAsyncRead-after-write for single aggregate
CASCADESyncSync (recursive)Synchronous cross-aggregate workflows

When sync_mode = CASCADE, the framework orchestrates the complete cascade:

flowchart TB
    Client --> CHC[CommandHandlerCoordinator.Handle<br/>Domain A]
    CHC --> BL[BusinessLogic.Handle → events]
    BL --> Persist[Persist events]
    Persist --> SC[SagaCoordinator.HandleSync]
    SC --> Saga[Saga.HandleSync → commands]
    Saga --> SC
    SC -.->|gRPC| CHC2[CommandHandlerCoordinator.Handle<br/>Domain B]
    CHC2 -.-> BL2[BusinessLogic.Handle → events]
    BL2 -.-> Persist2[Persist events]
    Persist --> PC[ProjectorCoordinator.HandleSync]
    PC --> Resp[CommandResponse<br/>events, projections]
    style CHC2 stroke-dasharray: 5 5
    style BL2 stroke-dasharray: 5 5
    style Persist2 stroke-dasharray: 5 5

The dashed Domain B represents the target aggregate—sagas bridge events from one domain to commands in another. The cascade recursively waits for the target aggregate’s events before returning.

Warning: CASCADE is expensive and does not provide ACID guarantees. Each step adds latency. Start with NONE, move to SIMPLE for read-after-write, reserve CASCADE for workflows requiring synchronous cross-aggregate coordination.

For observing events as they happen rather than waiting:

// EventStreamService: streams events to registered subscribers
service EventStreamService {
// Subscribe to events matching correlation ID (required)
// Returns INVALID_ARGUMENT if correlation_id is empty
// REST: Server-Sent Events (SSE) stream
rpc Subscribe(EventStreamFilter) returns (stream EventBook) {
option (google.api.http) = {get: "/v1/stream/{correlation_id}"};
}
}

Events are correlated via correlation_id on Cover, allowing clients to track causally-related events across aggregate boundaries.


⍼ Angzarr abstracts storage and messaging behind adapter interfaces.

BackendStatusUse Case
SQLiteTestedLocal development, testing
PostgreSQLTestedProduction
RedisTestedHigh-throughput scenarios
BigtableTestedGCP deployments, petabyte scale
DynamoDBTestedAWS deployments, serverless
immudbImplementedImmutable audit requirements

⍼ Angzarr uses publish/subscribe messaging to distribute events to sagas and projectors.

BackendStatusUse Case
RabbitMQ/AMQPTestedProduction
KafkaImplementedHigh-throughput streaming
GCP Pub/SubImplementedGCP deployments
AWS SNS/SQSImplementedAWS deployments

Configuration is declarative:

# Production
storage:
type: postgres
connection_string: ${DATABASE_URL}
bus:
type: amqp
url: ${RABBITMQ_URL}
# Local development
storage:
type: sqlite
path: ./data/events.db
bus:
type: channel

Every command, saga, and projector execution is traced and metered at the coordinator level—your code requires zero observability boilerplate.

PipelineTraced SpansMetrics
Aggregateaggregate.handle, aggregate.executeangzarr.command.duration
Sagasaga.orchestrate, orchestration.executeangzarr.saga.duration
Process Managerpm.orchestrateangzarr.pm.duration
Projectorprojector.handleangzarr.projector.duration

Every span carries the correlation_id, so distributed traces follow commands through aggregate execution, saga fan-out, and downstream projections without manual context propagation.

When built with --features otel, the sidecar exports traces, metrics, and logs via OTLP to any compatible backend (Grafana, Datadog, AWS X-Ray, GCP Cloud Trace).

See Observability for full details.


For visual explanations of event-driven architecture concepts (events, messaging patterns, event sourcing, choreography vs orchestration, schema management):