The real magic of CQRS isn’t about having two databases; it’s about the different shapes of those databases, optimized for fundamentally different jobs.

Let’s say we have a simple e-commerce system.

Write Side (Commands): When a customer places an order, we need to ensure data consistency. This means transactions, ACID properties, and a normalized schema.

// Example: Order Command
{
  "type": "PlaceOrder",
  "orderId": "ORD12345",
  "customerId": "CUST987",
  "items": [
    {"productId": "PROD001", "quantity": 2, "price": 10.50},
    {"productId": "PROD002", "quantity": 1, "price": 25.00}
  ],
  "timestamp": "2023-10-27T10:00:00Z"
}

Our write database, perhaps a traditional relational database like PostgreSQL, would store this in tables like Orders, OrderItems, and Customers, all normalized.

-- Example Write DB Schema Snippet
CREATE TABLE Orders (
    order_id VARCHAR(50) PRIMARY KEY,
    customer_id VARCHAR(50) NOT NULL,
    order_date TIMESTAMP NOT NULL,
    total_amount DECIMAL(10, 2) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'Pending'
);

CREATE TABLE OrderItems (
    order_item_id SERIAL PRIMARY KEY,
    order_id VARCHAR(50) NOT NULL REFERENCES Orders(order_id),
    product_id VARCHAR(50) NOT NULL,
    quantity INT NOT NULL,
    price DECIMAL(10, 2) NOT NULL
);

Read Side (Queries): Now, imagine we want to display a customer’s order history on a dashboard. We don’t need to join multiple tables, check for referential integrity, or worry about concurrent writes. We want a denormalized view that’s lightning-fast to query.

This is where a document database like MongoDB or a key-value store like Redis shines. We’d project the order data into a document optimized for this read.

// Example Read DB Document
{
  "_id": "ORD12345",
  "customerId": "CUST987",
  "orderDate": "2023-10-27T10:00:00Z",
  "totalAmount": 46.00,
  "status": "Pending",
  "customerName": "Alice Smith", // Denormalized for faster reads
  "items": [
    {"productId": "PROD001", "productName": "Widget", "quantity": 2, "price": 10.50},
    {"productId": "PROD002", "productName": "Gadget", "quantity": 1, "price": 25.00}
  ]
}

When an order is placed (written), an event is published. A separate process (an event handler or projection service) listens for this event and updates the read model in the read database.

The problem this solves is the impedance mismatch between the requirements of transactional writes and high-throughput reads. A single database trying to be good at both often ends up being mediocre at both. By separating them, we can choose the best tool for each job. The write side gets strong consistency and transactional integrity, while the read side gets performance and optimized data structures for specific queries.

The write database is optimized for commands, which are intent-based actions. The read database is optimized for queries, which are data retrieval requests. The data in the read database is often referred to as a "read model" or "projection," and it’s derived from the authoritative data in the write model.

The key insight is that the read models are eventually consistent with the write model. There will be a small delay between a write occurring and the read model being updated. This is a trade-off, but it’s usually a very acceptable one for the performance gains.

The most surprising thing is how much simpler your write model can become once you offload all read-specific concerns. You can remove indexes that only served specific read queries, simplify relationships, and focus purely on the business logic of accepting and validating commands.

The next step is understanding how to handle eventual consistency and potential conflicts when the read model becomes complex.

Want structured learning?

Take the full Cqrs course →