CQRS is a pattern that separates the concerns of reading data from the concerns of writing data, which can significantly improve scalability and performance in complex applications.

Let’s see CQRS in action with a simple Node.js and TypeScript example. Imagine a basic Product service where we need to fetch product details and update their prices.

// --- Domain Model (Write Side) ---
interface Product {
  id: string;
  name: string;
  price: number;
}

class ProductWriteRepository {
  private products: Map<string, Product> = new Map();

  async save(product: Product): Promise<void> {
    this.products.set(product.id, product);
    console.log(`[Write] Saved product: ${product.id}`);
  }

  async updatePrice(productId: string, newPrice: number): Promise<void> {
    const product = this.products.get(productId);
    if (product) {
      product.price = newPrice;
      await this.save(product);
      console.log(`[Write] Updated price for ${productId} to ${newPrice}`);
    } else {
      throw new Error(`Product with ID ${productId} not found.`);
    }
  }
}

// --- Query Model (Read Side) ---
interface ProductReadModel {
  id: string;
  name: string;
  price: number;
}

class ProductReadRepository {
  private products: Map<string, ProductReadModel> = new Map();

  async getById(productId: string): Promise<ProductReadModel | undefined> {
    console.log(`[Read] Fetching product: ${productId}`);
    return this.products.get(productId);
  }

  // This would typically be updated by an event handler listening to write-side changes
  async updateProductView(product: Product): Promise<void> {
    this.products.set(product.id, {
      id: product.id,
      name: product.name,
      price: product.price,
    });
    console.log(`[Read] Updated read model for product: ${product.id}`);
  }
}

// --- Application Logic ---
class ProductService {
  private writeRepository: ProductWriteRepository;
  private readRepository: ProductReadRepository;

  constructor(writeRepo: ProductWriteRepository, readRepo: ProductReadRepository) {
    this.writeRepository = writeRepo;
    this.readRepository = readRepo;
  }

  async createProduct(id: string, name: string, initialPrice: number): Promise<void> {
    const product: Product = { id, name, price: initialPrice };
    await this.writeRepository.save(product);
    // In a real system, this would trigger an event that an event handler
    // would use to update the read model. For simplicity, we'll update it directly here.
    await this.readRepository.updateProductView(product);
  }

  async changeProductPrice(productId: string, newPrice: number): Promise<void> {
    await this.writeRepository.updatePrice(productId, newPrice);
    // Again, this would ideally be event-driven.
    // For this demo, we simulate updating the read model after the write.
    const updatedProduct = await this.readRepository.getById(productId); // Fetch to get current state
    if (updatedProduct) {
      await this.readRepository.updateProductView({ ...updatedProduct, price: newPrice });
    }
  }

  async getProductDetails(productId: string): Promise<ProductReadModel | undefined> {
    return this.readRepository.getById(productId);
  }
}

// --- Setup and Execution ---
async function main() {
  const writeRepo = new ProductWriteRepository();
  const readRepo = new ProductReadRepository();
  const productService = new ProductService(writeRepo, readRepo);

  // Create a product
  await productService.createProduct("prod-123", "Awesome Gadget", 99.99);

  // Get product details
  let product = await productService.getProductDetails("prod-123");
  console.log("Initial Product:", product);

  // Update product price
  await productService.changeProductPrice("prod-123", 109.99);

  // Get product details again to see the update
  product = await productService.getProductDetails("prod-123");
  console.log("Updated Product:", product);
}

main();

This example demonstrates the separation: ProductWriteRepository handles the "how to save and update" logic, while ProductReadRepository handles the "how to fetch" logic. The ProductService orchestrates these, but in a real CQRS system, the write-side operations would typically publish events (e.g., ProductPriceUpdated), and separate "handler" services or functions would subscribe to these events to update the read models. This event-driven nature is key to achieving true decoupling and scalability.

The core problem CQRS solves is the impedance mismatch between the transactional, often complex, operations required to change an application’s state (the "write side") and the highly optimized, denormalized queries needed for efficient data retrieval (the "read side"). By treating these as separate concerns, you can scale them independently. The write side might use a traditional relational database optimized for ACID transactions, while the read side could leverage a document database, a search engine, or even a materialized view optimized for fast lookups.

The actual mechanism that synchronizes the write and read sides in a production CQRS system is usually an event bus or a message queue. When a write operation completes, it doesn’t directly update the read model. Instead, it publishes an event describing the change (e.g., ProductCreated, ProductPriceUpdated). Dedicated "projection" or "handler" services then consume these events asynchronously and update the appropriate read models. This asynchronous, decoupled approach allows the write side to remain fast and responsive, as it doesn’t wait for potentially slow read-side updates.

Most people don’t realize how much the shape of the data on the read side can diverge from the write side. For instance, a product might have a price on the write side, but the read model for a product listing page might need discountedPrice, formattedPrice, and isInStock flags. These derived, denormalized fields are computed by the event handlers as they process events, making the read queries incredibly efficient because all the necessary data is already present and optimized for retrieval, often without joins or complex transformations at query time.

The next concept to explore is how to handle eventual consistency and how to manage complex queries that might require joining data from multiple read models.

Want structured learning?

Take the full Cqrs course →