GraphQL and Event Sourcing/CQRS: Read Models as a GraphQL Schema
AI generated
{ }
type
GraphQL · Event Sourcing · CQRS · Projections
GraphQL and Event Sourcing / CQRS
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.

19 min read Event Store · Projections · Eventual Consistency · Subscriptions GraphQL 16 · CQRS · Event Sourcing

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.

11. FAQ: GraphQL and Event Sourcing/CQRS

1Why do they fit together?
GraphQL strictly separates query and mutation, mapping directly onto CQRS.
2Resolver accessing the event store directly?
No, too granular and too slow. Resolvers read exclusively from projections.
3What is a projection?
An event handler maintaining a denormalized read model for exactly one purpose.
4How to design the schema?
From the perspective of projections, not raw domain events.
5How do mutations work?
They translate input into commands for the domain handler, never write into a read model directly.
6What does eventual consistency mean?
A query right after a mutation can still return the old state.
7Solving read-your-writes?
Via an event sequence number in the mutation response passed to the following query.
8Why do subscriptions fit well?
Events are already time-ordered occurrences, ideal for real-time updates without polling.
9Adding a new read model?
Write a new projection, replay the full event stream, run in parallel without downtime.
10Worth it for every system?
No, pays off especially with high audit needs or strongly diverging read patterns.