"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.
Event Fix:
PriceUpdated.new(
product_id: 123,
old_price: 100,
new_price: 90,
actor: "[email protected]"
)
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.
Event Fix:
# Replay events *without* the ShipOrder command
events = EventStore.for(order_id).reject { |e| e.is_a?(OrderShipped) }
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.
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?
Event Fix:
Events::OrderPaid.new(items: order.items)
# Inventory service consumes event async
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?"
Event Fix:
EventStore.replay(at: "2023-05-10 02:43:00")
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
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
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:
- Add event publishing to one critical model.
- Keep using ActiveRecord for queries.
- Expand as pains emerge.
Have you hit a CRUD breaking point? Share your story below.
Top comments (0)