Architecture

View Source

evoq implements CQRS (Command Query Responsibility Segregation) and Event Sourcing patterns for Erlang applications. This guide explains the core architecture and how the components work together.

Architecture Overview

Core Principles

1. Commands Produce Events, Events Update State

The fundamental flow in evoq:

  1. Command arrives (user intent)
  2. Aggregate validates and produces Events (facts)
  3. Events are persisted to the event store
  4. Events are applied to update aggregate state
  5. Events are routed to subscribers (handlers, projections, process managers)

This separation ensures:

  • Commands can be rejected (validation failed)
  • Events are immutable facts (already happened)
  • State can be rebuilt by replaying events

2. Read Models Are Separate From Write Models

CQRS separates:

Write SideRead Side
CommandsQueries
AggregatesProjections
Event StoreRead Models
Business logicOptimized views

Benefits:

  • Read models can be denormalized for fast queries
  • Each read model optimized for specific use case
  • Read models can be rebuilt from events

3. Event Type Subscriptions (Not Stream-Based)

Unlike stream-based subscriptions, evoq routes events by type:

%% Handler declares interest by event type
interested_in() ->
    [<<"OrderPlaced">>, <<"PaymentReceived">>].

Why this matters:

  • 1 million orders = 1 million streams
  • Stream subscriptions = 1 million subscriptions (memory explosion)
  • Type subscriptions = ~10 event types (constant memory)

Component Overview

evoq_router

Entry point for command processing: validates the command, then hands it to evoq_command_router, which runs it through the middleware pipeline to the target aggregate.

%% Dispatch a command
{ok, Version, Events} = evoq_router:dispatch(Command).

%% The same dispatch, also returning the resulting aggregate state
{ok, Version, Events, AggregateState} =
    evoq_command_router:dispatch_with_state(Command, #{}).

Returning events (and, with dispatch_with_state/2, state) instead of a bare status is evoq's session consistency; see integration_actors.md#session-consistency for why the divergence from the reference CQRS model is deliberate.

evoq_middleware

Pluggable pipeline for command processing:

-behaviour(evoq_middleware).

before_dispatch(Pipeline) ->
    %% Validate, check idempotency, etc.
    {ok, Pipeline}.

after_dispatch(Pipeline) ->
    %% Post-processing
    {ok, Pipeline}.

Built-in middleware:

  • Validation - Schema and business rule validation
  • Idempotency - Prevent duplicate command processing
  • Consistency - Strong or eventual consistency guarantees

evoq_aggregate

Business logic container. Validates commands and produces events.

-behaviour(evoq_aggregate).

%% Initialize state
-callback init(AggregateId :: binary()) -> {ok, State :: term()}.

%% Validate and produce events
-callback execute(State :: term(), Command :: map()) ->
    {ok, [Event :: map()]} | {error, Reason :: term()}.

%% Update state from event
-callback apply(State :: term(), Event :: map()) -> NewState :: term().

evoq_event_router

Routes persisted events to interested subscribers by event type.

Subscribers:

  • Event Handlers (side effects)
  • Process Managers (orchestration)
  • Projections (read models)

evoq_event_handler

React to events with side effects:

-behaviour(evoq_event_handler).

interested_in() -> [<<"UserRegistered">>].

handle_event(EventType, Event, Metadata, State) ->
    send_welcome_email(Event),
    {ok, State}.

Features:

  • Retry strategies (exponential backoff)
  • Dead letter queue for failed events
  • At-least-once delivery behind the shared $all checkpoint: the subscription acknowledges a position for the node, not per handler, so after a crash the events since the last acknowledgement arrive again

evoq_process_manager

Coordinate long-running business processes. (The reference book is blunt that a process manager is not a saga; evoq used the words interchangeably.)

-behaviour(evoq_process_manager).

interested_in() -> [<<"OrderPlaced">>, <<"PaymentReceived">>].

correlate(Event, _Meta) ->
    {continue, maps:get(order_id, Event)}.

handle(State, Event, _Meta) ->
    Cmd = create_next_command(Event),
    {ok, State, [Cmd]}.

compensate(State, FailedCommand) ->
    {ok, [create_rollback_command(FailedCommand)]}.

evoq_projection

Build read models from events:

-behaviour(evoq_projection).

interested_in() -> [<<"OrderPlaced">>, <<"OrderShipped">>].

project(Event, Metadata, State, ReadModel) ->
    Updated = update_order_summary(Event, ReadModel),
    {ok, State, Updated}.

Data Flow

Command Processing

Command Dispatch

  1. Client creates command via evoq_command:new/4
  2. Dispatcher routes through middleware pipeline
  3. Aggregate executes command, produces events
  4. Events persisted to reckon-db
  5. Result returned to client

Event Distribution

Event Routing

  1. Events persisted to event store
  2. Event router receives notification
  3. Router looks up subscribers by event type
  4. Events delivered to matching handlers

Supervision Tree

evoq_sup
├── evoq_idempotency              # Command deduplication (starts first)
├── evoq_dead_letter              # Events no handler could process
├── evoq_checkpoint_store_ets     # Projection checkpoints, when used
├── evoq_aggregates_sup           # Dynamic aggregate supervisors
│   ├── evoq_aggregate_partition_sup_0
│   │   └── evoq_aggregate (bank_account, "account-018f6a7b8c9d4e5f60718293a4b5c6d8")
│   │   └── evoq_aggregate (bank_account, "account-018f6a7b8c9d4e5f60718293a4b5c6de")
│   ├── evoq_aggregate_partition_sup_1
│   │   └── ...
│   └── ... (4 partitions by default)
├── evoq_decisions_sup            # Decision actors
├── evoq_event_handler_sup        # Event handlers
│   └── evoq_event_handler (email_handler)
│   └── evoq_event_handler (audit_handler)
└── evoq_pm_sup                   # Process manager instances
    └── evoq_pm_instance (order_pm, "order-123")

Routers, registries, projections and store subscriptions are not under evoq_sup: the application starts them (evoq_event_router, evoq_event_type_registry, evoq_pm_router, evoq_projection:start_link/3, evoq_store_subscription:start_link/1), because it is the one that knows its store ids and read models.

Partitioned Supervision

Aggregates are distributed across 4 partition supervisors using consistent hashing. This prevents:

  • Single supervisor bottleneck
  • Cascade failures affecting all aggregates
  • Uneven load distribution

Integration with reckon-db

evoq uses reckon-db for event persistence:

%% Event store operations delegated to reckon-db through the adapter. A
%% subscription is the store's to deliver: evoq_store_subscription holds one
%% per node and routes what arrives to the handlers and process managers.
evoq_event_store:append(StoreId, StreamId, ExpectedVersion, Events).
evoq_event_store:read(StoreId, StreamId, StartVersion, Count, forward).
evoq_store_subscription:start_link(StoreId).

The adapter pattern allows different backends:

%% Configure adapter in sys.config
{evoq, [
    {event_store_adapter, reckon_evoq}
]}

Domain vs Integration Artifacts

evoq formalizes the distinction between internal and external data:

Domain ArtifactsIntegration Artifacts
TypesCommands, EventsFacts, Hopes
ScopeInside bounded contextCross boundaries
KeysAtomsBinaries (JSON-safe)
TransportIn-process, event storepg, mesh, RPC

Domain events are implementation details. Integration facts are explicit public contracts. A process manager decides which domain events become integration facts, transforming internal structure into stable external schemas.

Domain Event (internal)           Integration Fact (external)
  venture_initiated_v1       -->    <<"ventures.venture_initiated">>
  (atom keys, full state)          (binary keys, curated subset)

See the Artifacts Guide for detailed documentation and examples.

Next Steps