DEV Community

Alex Aslam
Alex Aslam

Posted on

When to Switch from CRUD to Events: The Tipping Point

"Your CRUD app works fine—until suddenly, it doesn’t."

You started with simple ActiveRecord models. Life was easy:
user.update!(name: "Alice")
Order.where(status: "completed")

But now:

  • Debugging means piecing together logs to answer, "How did this order total become $0?"
  • New features require hacking after_save callbacks into spaghetti.
  • Regulatory audits turn into SQL archaeology digs.

Event sourcing could help—but when is the tradeoff worth it?


1. The 5 Tipping Points

1. When "Who Changed This?" Matters

CRUD Struggle:

-- Who updated this price? When? Why? SELECT * FROM price_history WHERE product_id = 123; -- Oh wait, we didn’t log it. 
Enter fullscreen mode Exit fullscreen mode

Event Fix:

PriceUpdated.new( product_id: 123, old_price: 100, new_price: 90, actor: "admin@example.com" ) 
Enter fullscreen mode Exit fullscreen mode

Trigger: Compliance requirements or frequent debugging of data changes.


2. When Undo/Redo is a Business Need

CRUD Struggle:

# Accidentally shipped an order? Good luck. order.update!(status: "shipped") # Oops. 
Enter fullscreen mode Exit fullscreen mode

Event Fix:

# Replay events *without* the ShipOrder command events = EventStore.for(order_id).reject { |e| e.is_a?(OrderShipped) } 
Enter fullscreen mode Exit fullscreen mode

Trigger: User-facing undo features or complex workflows (e.g., cancellations).


3. When Scaling Writes and Reads Differently

CRUD Struggle:

Analytics query (5 sec) → Blocks checkout inserts → Revenue drops. 
Enter fullscreen mode Exit fullscreen mode

Event Fix:

  • Writes: Primary database
  • Reads: Projections from event streams (async updated)

Trigger: High-traffic systems where reads and writes compete.


4. When Cross-System Consistency is Critical

CRUD Struggle:

order.paid! inventory.reduce!(order.quantity) # What if this fails? 
Enter fullscreen mode Exit fullscreen mode

Event Fix:

Events::OrderPaid.new(items: order.items) # Inventory service consumes event async 
Enter fullscreen mode Exit fullscreen mode

Trigger: Distributed systems needing transactional guarantees.


5. When Time-Travel Debugging is Non-Negotiable

CRUD Struggle:

"Why did the system approve this fraudulent order at 2:43 AM?" 
Enter fullscreen mode Exit fullscreen mode

Event Fix:

EventStore.replay(at: "2023-05-10 02:43:00") 
Enter fullscreen mode Exit fullscreen mode

Trigger: Financial, healthcare, or security-sensitive apps.


2. The Migration Path

Step 1: Start Hybrid

# Legacy CRUD class Order < ApplicationRecord after_save :publish_event def publish_event EventStore.publish(OrderUpdated.from_model(self)) end end 
Enter fullscreen mode Exit fullscreen mode

Step 2: Shift New Features to Events

# New refund flow class IssueRefund def call(order_id) event = RefundIssued.new(order_id: order_id) EventStore.publish(event) # <- Source of truth end end 
Enter fullscreen mode Exit fullscreen mode

Step 3: Gradually Replace Legacy CRUD

  • Low-risk domains first (e.g., analytics → payments)
  • Build parallel projections
  • Sunset old code once projections are trusted

3. When to Stay with CRUD

🚫 Simple apps: Todo lists, basic CMS
🚫 Latency-sensitive writes: Ad bidding, gaming leaderboards
🚫 No audit requirements: Internal tools without compliance needs

Rule of Thumb:

Switch when debugging/scale pains cost more than event sourcing’s complexity.


"But We’re Not Amazon!"

You don’t need to be. Start small:

  1. Add event publishing to one critical model.
  2. Keep using ActiveRecord for queries.
  3. Expand as pains emerge.

Have you hit a CRUD breaking point? Share your story below.

Top comments (0)