"Your database is lying to you—and you don’t even know it."
CRUD (Create, Read, Update, Delete) seems straightforward:
✅ user.update!(name: "Alice")
– clean, simple, atomic.
But beneath the surface, naive updates hide dangerous traps:
- Lost data from race conditions
- No audit trails for critical changes
- Hidden coupling between features
Here’s what really happens when you trust UPDATE
too much—and how to fix it.
1. The Illusion of Atomicity
The Lie
"UPDATE
operations are atomic and safe."
The Reality
Race conditions lurk in read-modify-write cycles:
# Problem: Two threads read "balance = 100"
balance = user.balance # Thread A: reads 100
# Thread B: reads 100
user.update!(balance: balance + 50) # Thread A: writes 150
user.update!(balance: balance + 30) # Thread B: writes 130 (💥 overwrites A!)
Result: Lost update. Final balance should be 180, but it’s 130.
The Fix
- Database locks:
user.with_lock do
user.update!(balance: user.balance + 50)
end
- Optimistic concurrency:
user.update!(balance: user.balance + 30, version: user.version)
# Fails if `version` changed
2. The Vanishing History Problem
The Lie
"We can always check logs if something goes wrong."
The Reality
Most apps don’t log data changes, and even when they do:
- Logs expire.
- Correlations across tables are painful.
Example:
UPDATE orders SET status = 'refunded' WHERE id = 123;
-- Who triggered this? Why? When?
The Fix
- Event-driven sidecars:
after_update :log_change
def log_change
AuditLog.create!(
action: "update",
old_values: previous_changes,
user: Current.user
)
end
- Change Data Capture (CDC): Tools like Debezium stream DB changes to Kafka.
3. The Dependency Time Bomb
The Lie
"This column only affects one feature."
The Reality
A "simple" UPDATE
can trigger unexpected side effects:
class User < ApplicationRecord
after_update :send_welcome_email, if: -> { saved_change_to_status?(to: "active") }
after_update :update_search_index
after_update :notify_admin
end
# Later...
user.update!(status: "active") # 💥 Fires 3 callbacks, 2 external API calls
The Fix
- Explicit workflows: Replace callbacks with services.
UserActivator.new(user).call # Clearly does X, Y, Z
- Event-driven decoupling:
user.update!(status: "active")
EventBus.publish(UserActivated.new(user_id: user.id))
4. The Phantom Update Problem
The Lie
"UPDATE
only touches the columns you specify."
The Reality
ActiveRecord (and others) silently update all changed columns:
user.name = "Alice"
user.save! # Updates ALL dirty fields, not just `name`
Danger: If another thread changes user.email
concurrently, your save!
overwrites it.
The Fix
- Partial updates:
user.update_columns(name: "Alice") # Skips callbacks, touches only `name`
- Strict column lists:
user.assign_attributes(name: "Alice")
user.save(only: [:name])
5. When CRUD Is the Right Tool
✅ Simple admin dashboards
✅ Internal tools with no audit needs
✅ Early-stage prototypes
Rule of Thumb:
Use CRUD when "last write wins" is acceptable.
"But CRUD Is So Easy!"
It is—until it isn’t. Start small:
- Add auditing to one critical model.
- Replace one callback with a service.
- Measure the pain before going all-in on events.
Have you been burned by naive updates? Share your horror stories below.
Top comments (0)