Rails Style Guide: Transactions, Touch, and Async Derived Data
Rails Style Guide
Transactions, Touch, and Async Derived Data
This document defines official patterns and anti-patterns for handling transactions, derived data, touch, and async updates in Rails services at Reallyenglish.
The goal is to keep the system:
- Correct (core data is always valid)
- Performant (writes stay fast, reads stay cheap)
- Scalable (bursts and retries do not break invariants)
1. Data Classification (Mandatory Mental Model)
All data MUST be classified before implementation.
| Category | Examples | Consistency Requirement |
|---|---|---|
| Core data | domain truth, state machines, ownership | Strong (transactional) |
| Derived data | counters, summaries, rollups | Eventual |
| Cache | fragments, JSON blobs | Best-effort |
| Analytics | reports, metrics | Rebuildable |
Rule: Only core data belongs inside transactions.
2. Transactions: What Is Allowed
✅ Allowed inside transactions
- Creating/updating core records
- Enforcing invariants
- Validations that affect correctness
❌ Forbidden inside transactions
- Aggregate queries (
count,sum,exists?on associations) - Cache updates
- Summary updates
- Enqueuing async jobs (unless after_commit)
Approved pattern
1
2
3
ActiveRecord::Base.transaction do
address.update!(params)
end
3. Timeline Diagram: Transaction vs Async
❌ Bad (Derived work inside transaction)
1
2
3
4
5
6
7
8
9
Request
│
├─ BEGIN TRANSACTION
│ ├─ write core row
│ ├─ scan associations (slow)
│ ├─ update summary columns
│ └─ lock contention grows
│
└─ COMMIT
Problems:
- Long locks
- Deadlocks
- Slow user-facing writes
✅ Good (Core write + async)
1
2
3
4
5
6
7
8
9
10
11
12
Request
│
├─ BEGIN TRANSACTION
│ └─ write core row
│
├─ COMMIT
│
└─ after_commit
└─ enqueue async rollup
Background Worker
└─ rebuild derived data
Benefits:
- Short transactions
- Retry-safe work
- Predictable performance
4. touch: Official Usage
What touch IS
- A change signal
- A cache invalidation mechanism
- A dependency tracker
What touch IS NOT
- A summary calculator
- A counter updater
- A guarantee of correctness
Approved usage
1
2
3
class Address < ApplicationRecord
belongs_to :student, touch: true
end
Use touch to answer:
“Has anything under this record changed since I last looked?”
5. Derived Data Strategies (Choose One Explicitly)
Strategy A — Delta-based updates
Use when:
- Append-only data
- Clear +1 / -1 semantics
1
2
3
after_commit do
Student.update_counters(student_id, addresses_count: 1)
end
Pros: Fast reads, cheap writes Cons: Drift risk, retry sensitivity
Strategy B — Full recompute
Use when:
- Records can be edited or deleted
- Logic is conditional or complex
1
2
3
after_commit do
StudentRollupJob.perform_later(student_id)
end
Pros: Correct, idempotent Cons: More expensive
Strategy C — Dirty flag (recommended default)
1
2
3
4
# students.needs_address_rollup :boolean
Student.where(id: student_id)
.update_all(needs_address_rollup: true)
A worker periodically rebuilds flagged rows.
Pros: Burst-safe, scalable Cons: Slight staleness
Strategy D — Versioned touch (advanced, preferred for async)
1
2
3
4
# students.addresses_version :integer
Student.where(id: student_id)
.update_all("addresses_version = addresses_version + 1")
Jobs validate versions before writing.
Pros: Async-safe, cache-friendly Cons: More complex
6. Async Job Rules (Non-Negotiable)
All async jobs MUST be:
- Idempotent or discardable
- Safe to retry
- Safe to run out of order
❌ Forbidden
1
2
# blindly overwriting
student.update!(addresses_count: value)
✅ Approved
1
return if student.addresses_version > version_seen
7. Performance Rules
Avoid
- Touching parent rows in tight loops
- One job per child update
- Synchronous recomputation
Prefer
- Debouncing
- Deduped jobs by parent ID
- Periodic reconciliation
8. Reconciliation (Required)
Every derived column MUST have a reconciliation path.
1
2
3
4
5
6
Student.find_each do |student|
actual = student.addresses.count
next if actual == student.addresses_count
student.update!(addresses_count: actual)
end
Reconciliation enables:
- Aggressive optimization
- Drift detection
- Confidence in async design
9. Decision Checklist (Required Before Merge)
Before adding derived data, answer:
- Can this be rebuilt?
- Can it be stale for seconds/minutes?
- Can jobs retry or reorder?
- Can updates happen in bursts?
If yes to any → do NOT compute inside a transaction.
10. Final Principle
Transactions protect truth. Everything else is a snapshot.
Following this guide is mandatory for all new Rails code touching derived data.