Enterprise Patterns

CQRS

Separating reads and writes for scalability

CQRS (Command Query Responsibility Segregation) separates the models for reading and writing data. In PURISTA, this is a natural pattern: commands handle writes, and subscriptions build read models from events.

The separation

Commands (Write)Queries (Read)
ModelDomain model with validationOptimized read model
StorageState store / event storeSearch index / cache / materialized view
ConsistencyStrong (within command)Eventual (via subscription)
ScalingScale command handlersScale read replicas independently
flowchart LR
    C[Client] -->|write| CMD[Command]
    CMD -->|validate & mutate| SS[State Store]
    CMD -->|event| EB[Event Bridge]
    EB -->|event| SUB[Subscription]
    SUB -->|project| RS[Read Store]
    C -->|read| Q[Query]
    Q -->|fetch| RS

Example: order system

Write side (command)

const createOrderCommand = orderServiceBuilder
  .getCommandBuilder('createOrder', 'Create a new order')
  .addPayloadSchema(z.object({
    customerId: z.string(),
    items: z.array(z.object({ sku: z.string(), qty: z.number() })),
  }))
  .addOutputSchema(z.object({ orderId: z.string() }))
  .setCommandFunction(async function (context, payload) {
    const orderId = crypto.randomUUID()

    await context.states.setState(`order:${orderId}`, {
      customerId: payload.customerId,
      items: payload.items,
      status: 'created',
    })

    await context.emit('orderCreated', { orderId, customerId: payload.customerId })

    return { orderId }
  })

Read side (subscription + query)

// Subscription builds the read model
const orderProjection = queryServiceBuilder
  .getSubscriptionBuilder('orderProjection', 'Build order search index')
  .subscribeToEvent('orderCreated')
  .setSubscriptionFunction(async function (context, payload) {
    await context.resources.searchIndex.index({
      orderId: payload.orderId,
      customerId: payload.customerId,
      status: 'created',
    })
    return { status: 'ack' }
  })

// Query command returns from the read model
const searchOrdersCommand = queryServiceBuilder
  .getCommandBuilder('searchOrders', 'Search orders')
  .addParameterSchema(z.object({ customerId: z.string().optional(), status: z.string().optional() }))
  .setCommandFunction(async function (context, _payload, parameter) {
    return context.resources.searchIndex.search({
      customerId: parameter.customerId,
      status: parameter.status,
    })
  })

When to use CQRS

  • Read and write patterns differ significantly
  • Read models need to be optimized (search, aggregation, denormalized)
  • Different teams own reads vs. writes
  • Read scaling needs exceed write scaling
  • Event sourcing is already in use

When NOT to use CQRS

  • Simple CRUD with similar read/write patterns
  • Small systems where complexity exceeds benefit
  • Teams unfamiliar with eventual consistency
  • When strong consistency is required for all reads

Common pitfalls

  • Premature optimization. Start with a single model. Split when reads and writes genuinely diverge.
  • Ignoring eventual consistency. Read models may be stale. Design for it.
  • Over-complicating projections. Not every read needs a separate model.
  • Tight coupling between write and read. Read models should be rebuildable from events.

Checklist

  • Write and read models are genuinely different
  • Read models are optimized for query patterns
  • Eventual consistency is acceptable for reads
  • Read models can be rebuilt from events
  • Projection lag is monitored
  • Write and read sides scale independently

Related

Read Next
Observability

from Observability & Operations