DEV Community

Alex Aslam
Alex Aslam

Posted on

CQRS in Rails: Scaling Reads and Writes Independently

"Your database is fighting itself—and the reads are winning."

In most Rails apps, the same ActiveRecord model handles:
Writes (validations, callbacks, transactions)
Reads (API responses, dashboards, exports)

But when traffic grows, this becomes a zero-sum game:

  • Heavy reporting queries slow down writes
  • Write locks block frontend reads
  • Schema changes risk breaking both paths

Command Query Responsibility Segregation (CQRS) decouples reads from writes—letting you scale them independently.

Here’s how to implement it without leaving Rails behind.


1. CQRS in 60 Seconds

Traditional Rails

# User model does everything
class User < ApplicationRecord
  def activate!
    update!(active: true) # Write
  end

  def self.active_users
    where(active: true) # Read
  end
end
Enter fullscreen mode Exit fullscreen mode

CQRS Rails

# Write model (Command)
class UserCommand
  def activate(user_id)
    user = User.find(user_id)
    user.update!(active: true)
    UserReadModel.refresh(user) # Sync read model
  end
end

# Read model (Query)
class UserReadModel
  def self.active_users
    # Denormalized, optimized table
    FastUser.where(active: true).to_a
  end
end
Enter fullscreen mode Exit fullscreen mode

Key Idea:

  • Commands change state (and update read models)
  • Queries return data (never modify state)

2. Why This Matters

Problem: Reporting Queries Kill Checkout

-- Analytics query (5 sec)
SELECT * FROM orders
WHERE created_at > NOW() - INTERVAL '1 day'
GROUP BY region;

-- Blocks:
INSERT INTO orders(...) -- Checkout request
Enter fullscreen mode Exit fullscreen mode

CQRS Fix: Dedicated Read DB

# config/database.yml
production_readonly:
  adapter: postgresql
  host: read-replica.db.example.com
  replica: true
Enter fullscreen mode Exit fullscreen mode

Now:

  • Writes → Primary DB
  • Reads → Replica

3. Implementation Patterns

Pattern 1: Synchronous Denormalization

class PlaceOrderCommand
  def call(params)
    Order.transaction do
      order = Order.create!(params)
      OrderReadModel.create!(
        id: order.id,
        total: order.total,
        user_name: order.user.name # Denormalized!
      )
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Best for:

  • Simple apps
  • Strong consistency needs

Pattern 2: Event-Driven Updates

# Write side
event_store.publish(OrderPlaced.new(order_id: 123))

# Read side
class OrderPlacedHandler
  def call(event)
    order = Order.find(event.order_id)
    OrderReadModel.create!(order.attributes)
  end
end
Enter fullscreen mode Exit fullscreen mode

Best for:

  • High throughput
  • Eventually consistent systems

Pattern 3: Materialized Views

CREATE MATERIALIZED VIEW order_summaries AS
  SELECT
    orders.id,
    orders.total,
    users.name AS user_name
  FROM orders
  JOIN users ON users.id = orders.user_id;

REFRESH MATERIALIZED VIEW order_summaries;
Enter fullscreen mode Exit fullscreen mode

Best for:

  • Complex aggregations
  • Infrequently updated data

4. When to Avoid CQRS

🚫 Simple CRUD apps: Overengineering kills productivity
🚫 No performance issues: If it ain’t broke...
🚫 Tight budget: Replicas cost money

Golden Rule:

Adopt CQRS when scaling pain > implementation cost


5. Gradual Adoption Path

  1. Start with one hot query (e.g., User.active_count)
  2. Build a read model (sync via callbacks)
  3. Move to async updates (events, jobs)
  4. Scale reads independently (replicas, caching)

"But This Sounds Like Overkill!"

It can be—if you force it everywhere. Start small:

  1. Identify one bottleneck (e.g., slow dashboard)
  2. Build one read model
  3. Measure impact vs. complexity

Have you tried CQRS? Share your wins (or horror stories) below.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.