Jeffrey Hicks

Jeffrey Hicks

Platform Eng @R360

Read Model Reconstitution with GenServer in CQRS/ES Architecture

Perplexity research exploring a powerful read model reconstitution pattern that leverages CQRS principles and GenServer's stateful nature for living projections

By Agent Hicks • Aug 24, 2025 • perplexity-export

You’ve identified a powerful read model reconstitution pattern that leverages both CQRS principles and GenServer’s stateful nature. This approach creates a living projection that combines database persistence with in-memory evolution.

The Pattern: Database → GenServer → Evolution

Core Concept: Load a read model from database storage into GenServer state, then evolve it through incoming messages without persisting every intermediate state change. The database becomes your checkpoint, while the GenServer maintains the current evolved state.

defmodule WorkoutBuilder do
  use GenServer

  def start_link(workout_id) do
    GenServer.start_link(__MODULE__, workout_id, name: via_tuple(workout_id))
  end

  def init(workout_id) do
    # Reconstitute from read model projection
    initial_state = ReadModel.load_workout_projection(workout_id)
    {:ok, initial_state}
  end

  def handle_cast({:add_exercise, exercise}, state) do
    # Evolve state without database write
    new_state = WorkoutProjection.add_exercise(state, exercise)
    {:noreply, new_state}
  end

  def handle_call(:get_current_state, _from, state) do
    {:reply, state, state}
  end
end

System Design Benefits

Performance Advantages:

  • Microsecond reads from in-memory state vs database query latency
  • Zero database hits for intermediate state evolution
  • Real-time state access without eventual consistency delays

Consistency Guarantees:

  • Single-process serialization eliminates race conditions
  • Canonical source during active evolution phase
  • Process isolation prevents cross-aggregate contamination

Reconstitution Strategies

StrategyUse CaseTrade-offs
Cold StartSimple projections, stateless operationsFast startup, lose in-flight state
Warm StartComplex projections with computed fieldsSlower startup, preserve calculations
Event ReplayFull audit trail neededComplete history, expensive reconstitution
Snapshot + DeltaLarge state with frequent changesBalanced performance, complexity

Event Integration Patterns

Pattern 1: Projection-First Events update database projections, GenServer loads latest state on demand:

# Event handler updates read model
def handle(%ExerciseAdded{} = event, _metadata) do
  ReadModel.update_workout_projection(event.workout_id, event)
end

# GenServer reconstitutes when needed
def handle_info(:reconstitute, state) do
  fresh_state = ReadModel.load_workout_projection(state.id)
  {:noreply, fresh_state}
end

Pattern 2: Dual Evolution Events feed both persistent projections and active GenServer processes:

def after_update(%ExerciseAdded{workout_id: id} = event) do
  # Update persistent projection
  ReadModel.update_workout_projection(id, event)
  
  # Evolve active GenServer if running
  case GenServer.whereis(via_tuple(id)) do
    nil -> :ok
    pid -> GenServer.cast(pid, {:evolve, event})
  end
end

Lifecycle Management

Active Phase: GenServer maintains evolved state in memory

  • Commands operate on current GenServer state
  • Read operations return immediately from process
  • Database projection lags behind for crash recovery

Dormant Phase: State persisted, process terminated

  • Critical checkpoints written to database
  • Process can be restarted from last known state
  • Memory freed for other processes

Transition Management:

def handle_call({:publish_workout}, _from, state) do
  # Persist final state
  :ok = ReadModel.finalize_workout_projection(state)
  
  # Transition to read-only
  {:stop, :normal, :ok, state}
end

Crash Recovery Considerations

State Reconstruction Options:

  1. Last Known Projection: Fast startup, may lose recent changes
  2. Event Replay from Checkpoint: Guaranteed consistency, slower startup
  3. Hybrid Approach: Load projection + replay recent events

Example Recovery Logic:

def init(workout_id) do
  base_state = ReadModel.load_workout_projection(workout_id)
  last_event_id = base_state.last_processed_event_id
  
  recent_events = EventStore.read_from(workout_id, last_event_id)
  
  final_state = Enum.reduce(recent_events, base_state, fn event, state ->
    apply_event(state, event)
  end)
  
  {:ok, final_state}
end

Cross-Aggregate Challenges

Multi-Aggregate Projections: When your read model needs data from multiple aggregates, maintain bounded context separation:

def handle_cast({:add_candidate_skill, skill}, state) do
  # Don't query other aggregates directly
  # Instead, include necessary data in command
  new_state = add_skill_with_metadata(state, skill)
  {:noreply, new_state}
end

Recommendation Engine Pattern: Build skill-based projections that aggregate across multiple entities, then reconstitute specialized GenServers for active matching:

defmodule SkillMatcher do
  def init(skill) do
    # Load cross-aggregate projection
    candidates = ReadModel.candidates_with_skill(skill)
    recruiters = ReadModel.recruiters_needing_skill(skill)
    
    {:ok, %{skill: skill, candidates: candidates, recruiters: recruiters}}
  end
end

Production Implementation Guidelines

Memory Management:

  • Monitor process memory consumption for large projections
  • Implement cleanup strategies for long-running processes
  • Use ETS tables for shared read-only data

Supervision Strategy:

  • Temporary workers for short-lived evolution processes
  • Dynamic supervisors for on-demand process creation
  • Circuit breakers to prevent cascade failures

Monitoring and Observability:

  • Track reconstitution time for performance optimization
  • Monitor projection lag between database and GenServer state
  • Alert on excessive process memory usage

When to Use This Pattern

Ideal Scenarios:

  • Draft/Builder workflows where state evolves rapidly
  • Session-based interactions with complex state
  • Real-time collaboration requiring immediate consistency
  • Computationally expensive projections that benefit from caching

Avoid When:

  • Simple CRUD operations don’t justify the complexity
  • Cross-node sharing is required frequently
  • Memory constraints are tight
  • Audit requirements demand every state change be persisted

This pattern elegantly bridges CQRS principles with GenServer capabilities, providing immediate consistency during active phases while maintaining eventual consistency for dormant state. It’s particularly powerful for Phoenix LiveView applications where real-time UI updates benefit from microsecond state access.

References

  1. Building a CQRS web application in Elixir using Phoenix
  2. Commanded CQRS framework for Elixir
  3. Event Sourcing with Elixir
  4. Commanded Read Model Projections
  5. Choosing the Right In-Memory Storage Solution
  6. GenServer State Management
  7. Cross-Session State in Phoenix LiveView
  8. Real-World Event Sourcing
  9. CQRS with Commanded
  10. Building High-Performance APIs with Phoenix