"Your Rails app is drowning in SQL—and it’s time to throw it a lifeline."
ActiveRecord is the backbone of Rails… until it becomes the bottleneck. You know the pain:
- N+1 queries killing performance
- Callbacks triggering callbacks in a chaotic loop
- Business logic buried in SQL that no one dares touch
Command Query Responsibility Segregation (CQRS) offers an escape hatch. Here’s when—and how—to use it without leaving Rails behind.
1. What CQRS Actually Means
Traditional Rails (CRUD)
# One model handles everything
class Order < ApplicationRecord
def cancel!
update!(status: "cancelled") # Write
send_cancellation_email # Side effect
end
def self.recent_cancellations # Read
where(status: "cancelled").order(created_at: :desc)
end
end
CQRS Rails
Component | Responsibility | Example Tools |
---|---|---|
Commands | Change state | Service Objects, Events |
Queries | Return data | Read Models, SQL Views |
# Command (Write)
class CancelOrder
def call(order_id)
event = OrderCancelled.new(order_id: order_id)
EventStore.publish(event)
end
end
# Query (Read)
class CancelledOrdersReport
def last_week
# Optimized query, no callbacks
SQL.run("SELECT * FROM cancelled_orders_view WHERE date > NOW() - INTERVAL '7 days'")
end
end
2. When to Make the Jump
Green Flags for CQRS
✅ Reads vastly outnumber writes (e.g., dashboards, reports)
✅ Complex business logic (e.g., multi-step workflows)
✅ Need for multiple read models (e.g., API vs. admin views)
Red Flags (Stick with ActiveRecord)
🚫 Simple CRUD apps (e.g., internal tools)
🚫 Tight budget for infra (CQRS needs more databases)
🚫 Team new to DDD/CQRS (steep learning curve)
3. Implementing CQRS in Rails
Step 1: Split the Database
# config/database.yml
production_commands:
adapter: postgresql
database: app_commands
production_queries:
adapter: postgresql
database: app_queries
replica: true
Step 2: Replace ActiveRecord for Commands
class PlaceOrderCommand
def run(params)
event = OrderPlaced.new(params)
EventStore.publish(event) # Write to event log
InvalidateOrdersCache.call # Update read models
end
end
Step 3: Optimize Queries
# Bad (N+1)
Order.includes(:user).where(status: "completed")
# Good (pre-built view)
class CompletedOrdersQuery
def all
SQL.run("SELECT * FROM completed_orders_with_users")
end
end
4. The Dark Side of CQRS
Pitfall 1: Eventual Consistency
User clicks "Cancel Order" → UI still shows "Active" for 2 seconds
Fix:
- Use websockets to push updates
- Add "Pending" states in UI
Pitfall 2: Dual Writes
Event store updated → Read model fails to update
Fix:
- Transactional outbox pattern
- Dead letter queue for retries
Pitfall 3: Debugging Complexity
"Why is the read model showing old data?"
Fix:
- Trace IDs across commands/queries
- Replay tools for testing
5. Gradual Migration Path
- Start with one aggregate (e.g., Orders)
- Build parallel read model
- Redirect non-critical features
- Measure performance gains
"But ActiveRecord Is So Convenient!"
It is—until it isn’t. You don’t have to go all-in:
- Try CQRS for one report
- Keep ActiveRecord for simple CRUD
- Expand as bottlenecks emerge
Have you tried CQRS in Rails? Share your battle scars below.
Top comments (0)