GraphQL’s query/mutation split is a natural fit for CQRS, but thinking of them as just "read" and "write" misses a key nuance that makes integration powerful.
Let’s see this in action. Imagine a typical e-commerce scenario: a user browsing products and adding them to a cart.
GraphQL Schema Snippet:
type Product {
id: ID!
name: String!
price: Float!
description: String
}
type CartItem {
product: Product!
quantity: Int!
}
type Cart {
id: ID!
items: [CartItem!]!
totalPrice: Float!
}
type Query {
product(id: ID!): Product
products: [Product!]!
cart(id: ID!): Cart
}
type Mutation {
addToCart(productId: ID!, quantity: Int!): Cart
removeFromCart(productId: ID!): Cart
checkout(cartId: ID!): Order # Assuming Order type exists
}
Example Query (Read Side):
query GetProductAndCart($productId: ID!, $cartId: ID!) {
product(id: $productId) {
id
name
price
}
cart(id: $cartId) {
items {
product {
id
name
}
quantity
}
totalPrice
}
}
When this query hits your GraphQL server, it’s routed to a read model service. This service might query a denormalized, optimized view of your product catalog and the user’s cart. The key here is that the read model is built independently of the write operations.
Example Mutation (Write Side):
mutation AddItemToCart($productId: ID!, $quantity: Int!, $cartId: ID!) {
addToCart(productId: $productId, quantity: $quantity) {
id
items {
product {
id
}
quantity
}
totalPrice
}
}
This mutation, when executed, doesn’t directly update the read model. Instead, it sends a command to a write model service. This service validates the request, performs business logic (e.g., checking stock, applying discounts), and updates the authoritative state of the cart. Crucially, it then publishes an event (e.g., ProductAddedToCartEvent) to a message bus.
The read model service subscribes to these events. When a ProductAddedToCartEvent is received, the read model service updates its own local, optimized data store to reflect the change. This asynchronous propagation is the core of CQRS.
The actual problem CQRS solves here is decoupling the complexity of how data is written (command handling, business logic, eventual consistency) from the complexity of how data is read (optimized queries, diverse read models). With GraphQL, you get a unified API layer that can seamlessly expose both the read and write models. Your GraphQL resolvers for Query types will typically interact with your read model, while resolvers for Mutation types will orchestrate command dispatch to your write model.
The most surprising truth is that the totalPrice field in the Cart type, when accessed via a Query, is likely computed on the fly by the read model service after it has processed events reflecting the cart’s state. It’s not necessarily stored directly in the read model’s primary data store for cart items, but rather derived from the aggregated quantities and product prices that are stored. This allows the read model to maintain a flexible schema for its source of truth (the items and their quantities) while still efficiently serving derived data like the total.
The next logical step is to consider how to handle concurrent mutations and ensure data consistency across your read and write models.