modeling read models as a GraphQL schema
When the write side of a system lives in an event store, the read side does not have to use the same data model. GraphQL and event sourcing fit together surprisingly well, because GraphQL queries deliver exactly what CQRS read models need, flexible, projection-based queries without ever touching the event stream itself.
Table of Contents
- 1. Why GraphQL and CQRS naturally complement each other
- 2. The event store as the single source of truth
- 3. Projections: from event stream to read model
- 4. GraphQL schema design for projected read models
- 5. Mutations as command dispatch instead of direct writes
- 6. Explaining and surfacing eventual consistency to clients
- 7. Subscriptions: live updates straight from the event stream
- 8. Rebuilding projections without downtime
- 9. GraphQL over CQRS vs. GraphQL over CRUD compared
- 10. Summary
- 11. FAQ
1. Why GraphQL and CQRS naturally complement each other
Command Query Responsibility Segregation strictly separates write operations, commands, from read operations, queries, often with fully separate data models for each side. Event sourcing complements this pattern by having the write side store no current state at all, only the sequence of all domain events, from which the current state can always be recomputed. GraphQL and event sourcing fit together conceptually well, because GraphQL itself already enforces a strict separation between query and mutation in its type system.
The decisive advantage shows up in the read model: instead of maintaining a single, generic database table for all read access, as is common with classic CRUD, CQRS allows any number of specialized read models, each optimized for a specific access pattern. A GraphQL schema can elegantly expose this variety of read models as different fields and types, without the client ever needing to know how many different physical data sources actually make up the answer.
2. The event store as the single source of truth
At the center of every event sourcing system sits the event store, an append-only log of every domain event that has ever occurred, such as OrderPlaced, PaymentCaptured, OrderShipped. Unlike classic CRUD persistence, an existing record is never overwritten, every state change appears as a new, immutable event at the end of the log. This event store is the single authoritative source of truth in the entire system.
For GraphQL and event sourcing, that means: the GraphQL layer never accesses the event store directly to answer queries. The event store is too granular and too slow for typical read access, an order list with a hundred entries would mean replaying hundreds of event streams at query time. Instead, events serve solely as input for projections, which produce the actual read model optimized for GraphQL queries.
// Example domain events in the event store, append-only, immutable
{ "eventType": "OrderPlaced", "orderId": "ORD-9931", "sku": "SKU-4821", "quantity": 2, "occurredAt": "2026-08-06T09:00:00Z" }
{ "eventType": "PaymentCaptured", "orderId": "ORD-9931", "amount": 79.98, "occurredAt": "2026-08-06T09:00:04Z" }
{ "eventType": "OrderShipped", "orderId": "ORD-9931", "trackingId": "TRK-5512", "occurredAt": "2026-08-07T14:30:00Z" }
3. Projections: from event stream to read model
A projection is an event handler that reads the event stream and maintains a denormalized, query-optimized read model from it in its own table or document. Multiple different projections for the same domain can exist at once, one for the order overview in the customer account, another for the fulfillment dashboard in the warehouse, a third for reporting purposes. Every projection is tailored to exactly one reading purpose, instead of representing a universal compromise between all requirements.
For GraphQL and event sourcing it matters that every GraphQL query resolver is tied to exactly one projection, never to the event store directly. This mapping makes the resolver trivially simple: it is essentially a plain database lookup against an already precomputed table, with no domain logic, no event replay at request time. The entire complexity of state computation moves fully into the projection, far away from the GraphQL layer.
<?php
declare(strict_types=1);
namespace App\Projection\OrderSummary;
use App\Domain\Event\OrderPlaced;
use App\Domain\Event\OrderShipped;
use App\Domain\Event\PaymentCaptured;
/**
* Projects domain events into a denormalized, query-optimized
* read model dedicated to the customer account order overview.
*/
final class OrderSummaryProjection
{
public function __construct(
private readonly OrderSummaryWriteRepository $repository,
) {
}
/**
* Handles an OrderPlaced event and inserts a new summary row.
*
* @param OrderPlaced $event
* @return void
*/
public function onOrderPlaced(OrderPlaced $event): void
{
$this->repository->insert([
'order_id' => $event->orderId,
'status' => 'PLACED',
'sku' => $event->sku,
'quantity' => $event->quantity,
]);
}
/**
* Handles a PaymentCaptured event and updates the summary status.
*
* @param PaymentCaptured $event
* @return void
*/
public function onPaymentCaptured(PaymentCaptured $event): void
{
$this->repository->updateStatus($event->orderId, 'PAID');
}
/**
* Handles an OrderShipped event and updates status plus tracking.
*
* @param OrderShipped $event
* @return void
*/
public function onOrderShipped(OrderShipped $event): void
{
$this->repository->updateShipment($event->orderId, 'SHIPPED', $event->trackingId);
}
}
4. GraphQL schema design for projected read models
A common design mistake with GraphQL and event sourcing is deriving the GraphQL schema directly from the domain events, with one field per event type. That produces a technical schema unusable for clients. The right approach is to design the schema strictly from the perspective of the projections, meaning from what the client actually wants to read, independent of which events originally made up that state.
Since different projections can supply different views onto the same business entity, the schema needs to deliberately represent this variety, rather than forcing it artificially into a single object. An order in the customer account context needs different fields than the same order in the fulfillment context. Two separate GraphQL types, both ultimately fed from the same event stream, are often cleaner here than one single, overloaded Order type.
# Schema reflects the projection, not the raw event stream -
# each type maps to exactly one dedicated read model
type CustomerOrderSummary {
orderId: ID!
status: OrderStatus!
sku: String!
quantity: Int!
}
type FulfillmentOrder {
orderId: ID!
status: OrderStatus!
warehouseZone: String!
pickListPriority: Int!
}
type Query {
# Reads exclusively from the customer-facing projection
myOrders(customerId: ID!): [CustomerOrderSummary!]!
# Reads exclusively from the warehouse-facing projection
fulfillmentQueue(zone: String!): [FulfillmentOrder!]!
}
5. Mutations as command dispatch instead of direct writes
In a CQRS system, a GraphQL mutation never writes directly into a read model table. Instead, the mutation resolver translates the GraphQL input into a command, a business-named object such as PlaceOrderCommand, which gets passed on to the domain's command handler. The handler validates the command against the current aggregate state, reconstructed from events, and produces one or more new domain events in the event store on success.
This indirection is not an unnecessary detour in GraphQL and event sourcing, it is the actual core of the pattern: the mutation knows nothing about read models, it only knows commands and their immediate outcome. Updating the read models happens entirely asynchronously and decoupled, triggered by the newly written events, not by the mutation itself.
6. Explaining and surfacing eventual consistency to clients
The immediate consequence of section 5: right after a successful mutation, a following query can still return the old state, because the responsible projection hasn't processed the new event yet. This delay is usually in the low single-digit millisecond range, but it exists, and clients have to account for it. Anyone running GraphQL and event sourcing in production must never hide this eventual consistency, it has to be made explicitly visible in the schema.
A proven pattern: the mutation response includes not just the ID of the new entity, but also a sequence number or timestamp of the triggering event. A subsequent query call can pass this value as a parameter and instruct the server to respond only once the projection has caught up to at least that event level. This pattern, known as read-your-writes, solves the UX problem of delayed consistency without falling back to synchronous, tightly coupled write paths.
# Mutation returns the triggering event's sequence number,
# so the client can request read-your-writes consistency
type PlaceOrderPayload {
orderId: ID!
eventSequence: Int!
}
type Query {
# Waits until the projection has caught up to at least this sequence
myOrders(customerId: ID!, minEventSequence: Int): [CustomerOrderSummary!]!
}
7. Subscriptions: live updates straight from the event stream
GraphQL subscriptions are a natural partner for event sourcing, because both concepts rest on the same basic idea, treating state changes as discrete, time-ordered occurrences. A subscription resolver can attach directly to an aggregate's event stream and forward every relevant event to connected clients in near real time, with no polling and no artificial delay from batch projection runs.
It matters to route subscriptions in GraphQL and event sourcing through a light projection layer too, rather than passing raw events unchanged to the client. The client cares about the business progress of an order, not about the system's internal event structure. This translation layer decouples the public GraphQL schema from internal event schema changes, which would otherwise become breaking changes for every subscriber.
8. Rebuilding projections without downtime
A practical benefit of event sourcing shows up as soon as requirements for a read model change: a projection can be rebuilt from scratch at any time by replaying the entire event stream from the beginning. A new field in the GraphQL schema derivable from already existing historical events requires no data migration in the classic sense, just new projection logic and a full replay.
For this rebuild to happen without downtime, the new projection version has to be built up in parallel to the old one, in its own table, while GraphQL queries keep serving the old version. Only once the rebuild has fully caught up to the current event level does a simple alias or feature flag switch route queries to the new projection. Anyone running GraphQL and event sourcing this way can iterate on read models without ever interrupting the production query path.
9. GraphQL over CQRS vs. GraphQL over CRUD compared
The architectural shift to CQRS and event sourcing does not pay off for every system. The table below compares both approaches in a GraphQL context.
| Criterion | GraphQL over CRUD | GraphQL over CQRS/Event Sourcing |
|---|---|---|
| Read consistency | Immediately consistent | Eventual consistency, usually milliseconds |
| Historical traceability | Only with a separate audit log | Complete, every event is preserved |
| Adding new read models | Requires data migration | New projection, replay possible |
| Implementation effort | Low | Considerably higher |
| Read access scaling | Limited by a single schema | Independently scalable projections |
The additional implementation effort of CQRS and event sourcing pays off especially for domains with high audit requirements or strongly diverging read access patterns. For simple CRUD-heavy parts of a system, classic GraphQL over a relational database often remains the more pragmatic choice, even inside a system that uses event sourcing elsewhere.
Mironsoft
GraphQL architecture, event sourcing and Magento integration
GraphQL on top of your CQRS and event sourcing architecture?
We design a GraphQL schema with you that cleanly reflects your projections, implement read-your-writes consistency, and connect subscriptions directly to your event stream.
Schema design
Designing GraphQL types along your projections instead of raw events
Command dispatch
Cleanly implementing mutations as command dispatch instead of direct writes
Live subscriptions
Real-time updates straight from the event stream, decoupled from the internal event schema
10. Summary
GraphQL and event sourcing complement each other because GraphQL already enforces a strict separation between query and mutation, one that maps directly onto CQRS. The event store stays the single source of truth, GraphQL queries read exclusively from projections, specialized, denormalized read models computed from the event stream. Mutations translate into commands, never into direct writes of read models.
Eventual consistency has to be made explicitly visible to the client, for instance through read-your-writes parameters carrying event sequence numbers. Subscriptions benefit especially from this architecture, because events are already the natural shape of real-time updates. The additional implementation effort pays off especially for high audit requirements or strongly diverging read access patterns, not equally for every domain of a system.
GraphQL and Event Sourcing/CQRS — The Essentials at a Glance
Event store
The single source of truth, append-only, GraphQL queries never access it directly.
Projections
Specialized read models per access pattern, every query resolver serves exactly one projection.
Mutations as commands
No direct writes into the read model, mutations dispatch commands to the domain.
Eventual consistency
Make it explicitly visible in the schema, solve read-your-writes via event sequence numbers.