CQRS: fundamentals and practice in Elixir
Series: Part 2 of 5 — CQRS and architecture for AI agents.
Reading time: ~7 min.
In the previous article we saw why CRUD-centric backends bleed under concurrency and AI agents. Here we cover the conceptual solution (CQRS) and practice in Elixir with Queries and Commands.
The fundamentals of responsibility segregation (CQRS)
To go beyond the limits of traditional architectures, software engineering turns to CQRS (Command Query Responsibility Segregation). The concept derives primarily from the Command Query Separation (CQS) principle, coined by Bertrand Meyer in 1988 in the context of the object-oriented language Eiffel. CQS states that a function should either change system state (a command) or return a value (a query), but never both.
The CQRS pattern, popularized by Greg Young in the context of enterprise systems and Domain-Driven Design (DDD), elevates this principle from individual objects to the macro architecture. CQRS splits the logical flow and often the physical infrastructure of an application into two separate domains: the Command stack (Write Side) and the Query stack (Read Side).
The Command stack (the state transition machine)
In the CQRS world, a command is an explicit data structure that encapsulates the user’s or agent’s intention to perform a specific business task, such as BookHotelRoom instead of a generic instruction like SetReservationStatus. Other examples: CancelBooking, RefundPayment, ApproveExpense — each encapsulates a single intention and can have its own handler and business validations without mixing responsibilities. The write side of the system does not return domain data; it is strictly responsible for validating business rules, enforcing domain invariants, and persisting the new state.
By processing commands through specialized handlers, the system can resolve concurrency conflicts with high granularity, mitigating data merge issues in collaborative environments. If an operation requires long-running processes or fragile external dependencies, the command can be processed asynchronously via message queues, so the system absorbs demand spikes while degrading gracefully without compromising availability.
The Query stack (the optimized mirror)
The read model has a single vital responsibility: retrieve data and format it exactly as required by the user interface or the requesting module, without executing any business-rule validation. The CQRS pattern strongly encourages Materialized Views and purely denormalized models.
Denormalization becomes a powerful mechanism. Instead of computing complex aggregations or running multi-table joins at request time, the system pre-computes the results of complex queries and stores them in read-optimized databases (such as Redis, MongoDB, Elasticsearch, or dedicated vector databases). Thus the query becomes a simple key-value lookup (O(1)), eliminating latency and providing throughput capable of sustaining tens of thousands of concurrent requests. Example queries: GetBookingDetails, ListPendingApprovals, SearchAvailableRooms — each can be served by a different store (Redis, vector DB, PostgreSQL) without the write core needing to know.
Synchronization between the write core and materialized read views invariably happens asynchronously, via an event bus (e.g. Apache Kafka or RabbitMQ). This architecture introduces Eventual Consistency: it is guaranteed that, in the absence of new updates, all read replicas will converge to the final state, though there may be a delay of milliseconds between a write and its visibility on the read side.
In practice: Queries and Commands in Elixir
Talking about architecture without showing code is like selling a car without an engine. The Trips4you.Finance context in Elixir illustrates the split into two halves: Queries (what the system answers) and Commands (what the system does). Below, the boundary between asking and doing — two strictly separate facade modules, not one giant FinanceService with 50 methods.
The "Query" side: the system mirror
Take a look at the Trips4you.Finance.Queries module. It is the mirror that reflects the current state of the system to whoever is asking (whether the UI or an AI agent).
defmodule Trips4you.Finance.Queries do
# ... aliases omitted for brevity ...
def dashboard_summary(scope), do: DashboardSummary.execute(%DashboardSummary{scope: scope})
def list_group_itinerary(scope, group_id),
do: ListGroupItinerary.execute(%ListGroupItinerary{scope: scope, group_id: group_id})
def budget_dashboard(scope, group_id),
do: BudgetDashboard.execute(%BudgetDashboard{scope: scope, group_id: group_id})
# ... dozens of read functions ...
end
Why is this powerful?
-
Zero side effects: You can call
budget_dashboardten thousand times per second. Nothing in the write database will change, no email will be sent, no payment will be triggered. -
Extreme optimization: If
list_group_itineraryis slow, the team can change that command’s internal implementation to read from a Redis cache or a MongoDB database without the frontend changing a single character of the original call. - Asymmetric scalability: Your AI agents probably read context all the time. You can allocate more nodes/servers just for the Query infrastructure.
The "Command" side: the change engine
Now look at the Trips4you.Finance.Commands module. Here the game changes. These are imperative orders. The system is not just answering; it is executing actions that change the world.
defmodule Trips4you.Finance.Commands do
# ... aliases omitted for brevity ...
def create_group(scope, attrs),
do: CreateGroup.execute(%CreateGroup{scope: scope, attrs: attrs})
def add_group_member(scope, group_id, user_id),
do: AddGroupMember.execute(%AddGroupMember{scope: scope, group_id: group_id, user_id: user_id})
def liquidate_group_with_wallets(scope, group_id, opts \\ []),
do: LiquidateGroupWithWallets.execute(%LiquidateGroupWithWallets{
scope: scope,
group_id: group_id,
opts: opts
})
# ... write and state-changing commands ...
end
The power of granular commands:
-
Clear intention: A command like
LiquidateGroupWithWalletsis not a genericupdate_group(). It carries a very specific business intention. - Isolated rules: The heavy validation for whether a group can be liquidated lives only inside that command. The Query side does not even know that rule exists.
-
End of conflicts: If "Agent A" dispatches the
add_group_membercommand and "Agent B" dispatchesupdate_group_trip_windowat the same time, the actions do not collide. The system processes the intentions separately instead of trying to save the entire "Group" object at once (the classic CRUD problem).
The facade hides the complexity
The beauty of this structure is that whoever consumes this API (your web controller, a WebSocket channel, or an autonomous agent routine) does not need to know anything about the underlying architecture.
They do not need to know whether you use Postgres, Kafka, RabbitMQ or Event Sourcing. They just dispatch commands to the engine and read data from the mirror.
Enjoyed it? Support the author with a coffee.
Previous: Why CRUD bleeds (and what you feel)
Next: Ask vs Act: RAG, Tool Use and AI agents
Top comments (0)