An invariant is a rule that must hold on every write path — see invariants for the underlying concept. A convention is a rule that holds only on the write paths whose authors remembered to honour it. The two are not interchangeable, and the central mistake this note is about is enforcing an invariant with a convention.

A callback is a suggestion, not a plan.

A lifecycle hook (a Rails before_save, an ORM callback, a “remember to call normalize() first” team agreement) is a convention. It runs on the paths that go through it and is silently skipped by the ones that don’t. Invariants belong in the data layer; behaviour belongs in explicit objects.

The bypass list is published; the invariant isn’t

The sharp distinction: a database constraint has no hole. A unique index has no insert_all-shaped bypass, no “but this admin script writes directly” exception. A callback-based rule, by contrast, has a published bypass list — every bulk or low-level write path (update_all, insert_all, update_column, delete_all, raw SQL) goes around it by design.

The bypass list is published; the invariant isn’t.

So the failure is silent and at the wrong layer: the data ends up invalid, and nothing complained at write time. A guarantee with a bypass list is not a guarantee.

Two blind spots pointed at each other

The deeper reason hidden behaviour-on-persistence is dangerous:

A callback is control flow attached to persistence that the call site can’t see, written by an author who can’t see the call sites: two blind spots pointed at each other.

The caller can’t tell that saving will also fire three other writes; the callback author can’t tell which of the dozens of call sites are about to trigger it. That mutual invisibility is the antipattern, independent of language or framework — any “do extra work implicitly whenever this data changes” mechanism has it.

Where each kind of rule belongs

  • Hard invariants (“a seat is reserved at most once”, “an email is unique”) → database constraints: unique indexes, CHECK, foreign keys. Enforced on every write, forever, for callers you’ll never meet.
  • Normalisation of a record’s own fields (downcasing an email) → a pure transformation of the record’s own attributes is the one safe kind of hook; it reads and writes only itself.
  • Cross-record writes and orchestration → an explicit command object with one visible entry point, not an implicit cascade. See single ingress.
  • Side effects (email, webhooks, search indexing) → after the transaction commits, triggered explicitly — never wired to “whenever this saves”.
  • Cascade cleanup → foreign-key ON DELETE, not a destroy callback that bulk deletes skip.
  • Observability → events / pub-sub (domain events) where subscribers passively watch and never become required domain behaviour.

Convention is fine — until enforcement matters

A convention isn’t worthless; on a small, cohesive team a disciplined callback is often plenty. It becomes a liability precisely when enforcement that relies on people reading docs stops being enough — large teams, mixed experience levels, long-lived code. At that point you want mechanisms (database constraints, a single write door, lint rules that fail CI) rather than culture. That trade-off — when to trust discipline vs. when to mechanise — is the subject of vanilla Rails vs. the sharp parts.

My read

This crystallised something I’d felt but couldn’t name: the layer a rule lives in determines who can break it. A rule in a callback can be broken by anyone who takes a bypass path and never reads the callback. The same rule as a constraint can’t be broken at all. Coming from a world of validation attributes and domain-layer guards, the upgrade is to stop asking “did I put a check here?” and start asking “is there any write path that reaches this data without the check?” — and if the answer can ever be yes, push it down to a constraint. This is the invariants idea (“a class should exist iff it maintains an invariant”) extended past the object boundary into the database.

Further reading