Rails architecture

Callbacks, invariants, and durable events in Rails

A practical reading of where business rules should live, when callbacks are acceptable, and why some state changes deserve durable event and delivery records.

The core question

The issue is not whether Rails callbacks are good or bad in the abstract. The practical question is narrower: when a rule must always hold, where should that rule live, and how should the effects of a state change be made safe?

Consider a simple rule: a seat cannot be reserved twice. That rule is not the same kind of thing as “send an email after reservation” or “update a search index after a page changes.” The first is a correctness rule. The others are effects that follow from a change.

Useful framing: a business rule protects state; a side effect reacts to state. Treating both as the same kind of callback makes the code easy to write but harder to reason about later.

Rails makes it convenient to attach behavior to persistence events: before save, after save, after commit, after destroy, and so on. That convenience is useful. It also means a call like record.save! can run a larger program than the caller can see. Some of that program may validate or normalize data. Some may write other rows. Some may enqueue jobs or notify external systems. Some may not run at all if the write path bypasses callbacks.

The shape of the solution depends on the kind of behavior being protected. Serious rules need explicit ownership and, often, database enforcement. Ordinary local propagation may be fine as callback-driven lifecycle work. External delivery usually deserves persisted evidence that the work is owed and a record of whether it succeeded.

Terms without buzzwords

The same conversation often sounds more complicated than it is because different communities use different terms for simple ideas.

Term Plain meaning What to watch for
Callback Code Rails runs automatically when a record is saved, created, updated, destroyed, or committed. The caller may not see it, and some write APIs skip it.
Invariant A rule that must remain true for the data to make sense. It should not depend only on a callback that can be bypassed.
Ingress The entry point where a write enters the application. One visible entry point helps, but it is not the same as owning the rule.
Command or service object A named operation such as ReserveSeat.call(...). It makes the flow visible, but can become a script that manipulates passive records.
Aggregate The object or small cluster of objects that owns a consistency boundary. It should reject invalid changes to its own state.
Domain event A stored fact that something meaningful happened. It is stronger than an in-memory notification.
Outbox A database-backed queue of facts or messages that must be delivered later. It closes the gap between committing data and notifying other systems.

Why callbacks are sharp

Callbacks are not wrong by definition. They become risky when they are treated as the place where critical rules live or as proof that an effect always happens.

Some paths skip them

Methods such as update_all, insert_all, upsert_all, delete_all, and update_columns can bypass validations and callbacks. That is useful for speed and bulk operations, but it means a callback is not a universal guard.

Some paths run more than expected

Associations can add callbacks implicitly. A save can touch parent rows, update counters, or trigger dependent behavior that the call site does not mention.

Timing matters

after_save runs inside the transaction. after_commit runs after the database commit. Neither is automatically the right place for every effect.

Order is real control flow

Callbacks, validations, concerns, association options, and conditional hooks create an execution chain. That chain can become a state machine that no one explicitly designed.

A practical consequence is that callbacks are weaker than database constraints for protecting data. A unique index does not care whether a row was inserted through a model, a bulk API, a console script, or a background job. It applies to every write path. A callback applies only to paths that run the callback chain.

Two useful design pressures

There are two complementary ways to improve over “put it in a callback so it always happens.” Each solves a different problem.

Make important writes explicit

One useful pressure is to give important operations named entry points. A reservation, cancellation, triage, closure, or publication is not just a generic update. It has a name in the product, so it deserves a name in the code.

# Simplified shape
ReserveSeat.call(seat_id:, by:)

# Inside the operation:
# - load the seat
# - lock or otherwise protect the relevant row
# - check whether the action is allowed
# - write the new state
# - record related facts
# - arrange follow-up work deliberately

This makes call sites easier to audit. Instead of searching for every place that writes reserved_by, the team can look for the reservation operation. Tooling can reinforce that boundary by discouraging direct writes from unrelated parts of the application.

Let the object protect its own state

Another useful pressure is to put the rule on the object whose state is being protected. The outer operation should not be the only place that understands whether a seat can be reserved or a card can be closed.

# Simplified shape
Seat.transaction do
  seat = Seat.lock.find(seat_id)
  event = seat.reserve(by: user)
  seat.save!
  EventStore.record(event)
end

In that shape, the entry point coordinates the operation, while the seat owns the rule. The distinction is small in trivial examples and important in larger systems. A visible operation helps readers find the flow. A state-owning method helps keep the rule close to the data it protects.

These pressures are compatible. A Rails application can have explicit entry points for important operations and still put the core rule on the model or aggregate that owns the state.

A Rails-shaped synthesis

A balanced Rails design usually separates four categories of behavior.

Behavior Good home Reason
Hard data rule Database constraint, transaction, lock, and/or model method that refuses invalid state. The rule should hold regardless of which application path writes the row.
Named business action Model method, aggregate method, or explicit operation. The product action becomes visible and searchable in code.
Local secondary propagation Callback or concern, when failure is tolerable or repairable. Search indexing, cache invalidation, unread markers, and touch behavior often fit this category.
External delivery Persisted event and delivery records, with jobs that can retry and record results. HTTP calls, webhooks, email, and integrations can fail after the database transaction succeeds.

The important distinction is not “callbacks or no callbacks.” It is whether the mechanism matches the failure mode. A callback can be a reasonable local hook. It is a poor sole owner of an invariant. A job can be a reasonable delivery mechanism. It is incomplete if the application has no durable record that delivery was owed.

What 37signals code shows

Public 37signals Rails code does not fit a strict “everything through service objects” style, and it also does not treat every state change as requiring a formal event pipeline. The code shows ordinary Rails models with behavior, callbacks for lifecycle work, transactions where state changes need grouping, and persisted delivery machinery when the product feature requires it.

37signals has also publicly defended using Rails features such as callbacks while drawing a boundary around complex orchestration. The practical reading is: callbacks are available, but they are not the only architectural tool.

Campfire: callbacks for local message propagation

In the ONCE Campfire codebase, message creation follows a direct Rails shape. The controller creates a message through the room association, broadcasts it, and delivers bot webhooks. The Message model also has an after_create_commit hook that calls room.receive(self). The room then marks memberships unread and enqueues a push job.

# Simplified shape
message = room.messages.create_with_attachment!(params)
message.broadcast_create

after_create_commit { room.receive(self) }

This is a callback used as lifecycle wiring. It is not a global event architecture. It is also not the only protection boundary in the app: user creation rescues a database uniqueness violation, showing that the database is expected to enforce at least some race-sensitive facts.

Writebook: named model methods plus callback-maintained indexes

In Writebook, creating content is expressed through a named model method: a book can press a new leaf. Editing content goes through Leaf#edit, which can update current content, create revision rows, or record a trash edit. The model owns a meaningful product operation.

# Simplified shape
leaf = book.press(new_leafable, leaf_params)
leaf.edit(leafable_params:, leaf_params:)

Writebook also uses callbacks for full-text search maintenance. A searchable leaf updates the search index after create, update, or destroy commits. That is local derived data. If it drifts, it can be rebuilt. This is a different category from a rule such as “two users cannot own the same unique account identity.”

Fizzy: persisted events and deliveries for webhooks

Fizzy is the clearest public example of durable event and delivery machinery because webhooks are a product feature. A webhook is not just internal cleanup. It is a promise to call an external system, record whether the call worked, expose delivery history, and eventually deactivate endpoints that keep failing.

That need changes the design. Fizzy records an Event row when meaningful board activity happens. It then creates Webhook::Delivery rows for subscribed webhooks, and each delivery records state such as pending, in progress, completed, or errored.

Concrete Fizzy example: card triage to webhook delivery

The product action is moving a card out of triage into a column. In Fizzy, the controller does not manually script every side effect. It calls a named model operation, Card#triage_into(column).

# Simplified from Card::Triageable
card.triage_into(column)

# The model operation:
# - checks that the column belongs to the same board
# - resumes the card if needed
# - updates the card's column
# - records a "card_triaged" event inside the transaction

The important part is that the event is recorded in the same transaction as the card state change. The event is not just an in-memory notification. It is a database row attached to the board, creator, eventable record, action name, and particulars.

1
Card operationCard#triage_into(column) updates the card and calls track_event in the transaction.
2
Event rowtrack_event creates a persisted Event, such as card_triaged.
3
After commit dispatchAfter the event commits, Fizzy enqueues a job to find active webhooks subscribed to that event.
4
Delivery rowFor each subscribed webhook, Fizzy creates a Webhook::Delivery row.
5
HTTP deliveryThe delivery job performs the HTTP request, stores request and response details, and marks the delivery completed or errored.
6
Failure accountingA delinquency tracker counts repeated failures and can deactivate a webhook that remains unhealthy.

This is durable machinery introduced for a concrete product requirement. External integrations are slow, unreliable, and observable by customers. A transient callback or best-effort job enqueue would not provide enough operational information. The delivery row gives the application something concrete to inspect, retry around, summarize, and clean up.

Precision matters: this is not the same as a full global transactional outbox for every state change. Fizzy persists business events and webhook deliveries for webhook integrations, while still using after_commit callbacks to enqueue dispatch and delivery jobs. The durable rows exist where the product needs history and delivery state.

Decision guide

The recurring pattern is to match the mechanism to the consequence of failure.

Question Likely mechanism
Would corrupted data break the product? Use database constraints, transactions, locks, and model methods that reject invalid state.
Is this a named user action? Give it a named method or operation so callers do not perform a pile of anonymous updates.
Is the work derived, local, and rebuildable? A callback can be acceptable, especially after commit.
Does another system need to be told? Persist an event or delivery obligation before attempting the external effect.
Does failure need to be visible to an admin or customer? Store request, response, state, timestamps, and failure counts.
Can the effect be lost with no serious consequence? A simpler callback or job may be enough.

The resulting style is pragmatic: use callbacks as wiring, not as the sole source of truth; put meaningful behavior close to the model or object whose state changes; use explicit entry points for operations people need to reason about; and persist facts or delivery records when a later effect must not disappear silently.

Explicit writes Model-owned rules Database constraints Lifecycle callbacks Durable delivery

Sources and code references

The article uses short, simplified code shapes for readability. The links below point to the relevant source material and public code references.

  1. Rails: The Sharp Parts. Callbacks Are Not Invariants
  2. Ingress is not the owner of the invariant
  3. 37signals Dev: Globals, callbacks and other sacrileges
  4. Basecamp ONCE repository
  5. ONCE Campfire repository
  6. Campfire MessagesController
  7. Campfire Message model
  8. Campfire Room model
  9. Campfire UsersController
  10. Writebook repository
  11. Writebook LeafablesController
  12. Writebook Book model
  13. Writebook Leaf::Editable
  14. Writebook Leaf::Searchable
  15. Fizzy repository
  16. Fizzy Webhooks help page
  17. Fizzy Card::Triageable
  18. Fizzy Eventable concern
  19. Fizzy Event model
  20. Fizzy Event::WebhookDispatchJob
  21. Fizzy Webhook::Triggerable
  22. Fizzy Webhook::Delivery
  23. Fizzy Webhook::DeliveryJob
  24. Fizzy Webhook::DelinquencyTracker