"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
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
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
CQRS Fix: Dedicated Read DB
# config/database.yml
production_readonly:
adapter: postgresql
host: read-replica.db.example.com
replica: true
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
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
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;
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
-
Start with one hot query (e.g.,
User.active_count
) - Build a read model (sync via callbacks)
- Move to async updates (events, jobs)
- Scale reads independently (replicas, caching)
"But This Sounds Like Overkill!"
It can be—if you force it everywhere. Start small:
- Identify one bottleneck (e.g., slow dashboard)
- Build one read model
- 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.