Perplexity research exploring a powerful read model reconstitution pattern that leverages CQRS principles and GenServer's stateful nature for living projections
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.
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
Performance Advantages:
Consistency Guarantees:
Strategy | Use Case | Trade-offs |
---|---|---|
Cold Start | Simple projections, stateless operations | Fast startup, lose in-flight state |
Warm Start | Complex projections with computed fields | Slower startup, preserve calculations |
Event Replay | Full audit trail needed | Complete history, expensive reconstitution |
Snapshot + Delta | Large state with frequent changes | Balanced performance, complexity |
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
Active Phase: GenServer maintains evolved state in memory
Dormant Phase: State persisted, process terminated
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
State Reconstruction Options:
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
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
Memory Management:
Supervision Strategy:
Monitoring and Observability:
Ideal Scenarios:
Avoid When:
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.