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: "[email protected]"
)
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)