DEV Community

Alex Aslam
Alex Aslam

Posted on

Rails Without ActiveRecord: When to Go Full CQRS

"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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

4. The Dark Side of CQRS

Pitfall 1: Eventual Consistency

User clicks "Cancel Order" → UI still shows "Active" for 2 seconds
Enter fullscreen mode Exit fullscreen mode

Fix:

  • Use websockets to push updates
  • Add "Pending" states in UI

Pitfall 2: Dual Writes

Event store updated → Read model fails to update
Enter fullscreen mode Exit fullscreen mode

Fix:

  • Transactional outbox pattern
  • Dead letter queue for retries

Pitfall 3: Debugging Complexity

"Why is the read model showing old data?"
Enter fullscreen mode Exit fullscreen mode

Fix:

  • Trace IDs across commands/queries
  • Replay tools for testing

5. Gradual Migration Path

  1. Start with one aggregate (e.g., Orders)
  2. Build parallel read model
  3. Redirect non-critical features
  4. Measure performance gains

"But ActiveRecord Is So Convenient!"

It is—until it isn’t. You don’t have to go all-in:

  1. Try CQRS for one report
  2. Keep ActiveRecord for simple CRUD
  3. Expand as bottlenecks emerge

Have you tried CQRS in Rails? Share your battle scars below.

Top comments (0)