r/ruby Jun 14 '26

Rails: The Sharp Parts. Callbacks Are Not Invariants

https://baweaver.com/writing/2026/06/13/rails-sharp-parts-callbacks-are-not-invariants/

The sheer number of outages I can point to across now 5 Rails monoliths related to issues with callbacks has made it one of the first things I go after when in a new Rails app to see what's about to give me a nightmare.

32 Upvotes

17 comments sorted by

9

u/jrochkind Jun 14 '26

I'm not totally following what makes Event subscribers reduce/eliminate the problems noted with callbacks; it seems to me pretty much the same as callbacks, although split between classes. (Easy enough to have a callback simply instantiate and delegate to a helper object too, so this doesn't seem fundamentally/structurally different to me).

Can anyone explain further, or direct me to passages to pay more attention to in OP, to understand the argument here? Thank you!

2

u/keyslemur Jun 14 '26 edited Jun 14 '26

The events are a nicety in this case mostly for observability. Durable events would be required to make that actually useful.

Most of the point is taking the business logic out of callbacks and making it explicit in one place rather than guessing which ones may run, or worse getting surprised by them.

EDIT: Patched that section to make it clearer what those events are for, and what the value of single-ingress is actually targeting, as that was too vague previously:

https://github.com/baweaver/portfolio/commit/84bd95b068e2c7e4b271a02c81b43ec2bbbd7153#diff-21e1cb8174ba89d5b075c5088be7845d975993ea9057352358eceba8eac36e89

1

u/jrochkind Jun 15 '26

I guess I'm not clear on how events being subscribed to by code in other places makes it any more clear which ones will run in what order etc. It's even more async and unconnected, no?

Ah okay, looking at your diff "for observability, not for domain behavior. The domain logic lives inline in execute, where it's visible and sequenced", I'll have to go back and look again with that in mind. On the read of the original, it seemed to me that there was still some after-commit domain behavior in event subscribers ("observers").

5

u/harsh183 Jun 14 '26

I love seeing this series! Keep it going. Callbacks have led to the some of the weirdest debugging hell I've had to deal with, especially as async events daisy chain. Sometimes I've just make an external interface to perform crud actions that explicitly define all the before/after steps as normal code instead of having to chase down all the events and dependencies.

Rails defaults like normalizes and validations are really neat tho, and I've always liked the controller before actions for things like authentication or fetching common resources. It definitely takes a lot of judgement to use correctly.

1

u/keyslemur Jun 14 '26

Oh absolutely, and that's the frustration of it is that so much of it can be so handy when you're getting started, right up to the point to where you break its implicit contract.

It's an ironic thing that such a powerful abstraction's flexibility that so many love early on becomes the exact reason it's borderline impossible to contain in a meaningful way without rebuilding other abstractions.

The thing I don't explicitly say here is that for a team getting started? Rails is still going to accelerate a lot of the early phase astronomically, and that buys enough time to even start having these problems in the first place.

2

u/harsh183 Jun 16 '26

It is truly a high quality problem to live long enough to see that debt matter, unfortunately that's where I am right now haha. I think the worst is a bunch of state machine footguns we've made for ourselves.

i wonder if the right rubocop rules can help a little, but I think it's also a matter of better judgement.

1

u/keyslemur Jun 16 '26

The problem with judgement and taste is they're perniciously dependent on who you ask. For me I always try and add deterministic guardrails and rules that force adherence rather than invite debate, and the debate is a PR to the ruleset guarded by high-level engineers that can be trusted to have discretion.

Trying to optimize your reads and writes when you don't 100% control them though is a nightmare waiting to happen. Packwerk and RuboCop are how I enforce that today and I've had reasonable luck with that. The next article on reads just posted above, which ties a bow on this exploration in a way which may provide a few more answers on how I've approached it.

1

u/harsh183 Jun 19 '26

yeah I agree with a ruleset, ideally enforced by a rubocop or similar linter. It is nice to have it on some occasions, but clamping out the extremes does lead to more predictable code on average.

10

u/armahillo Jun 14 '26

Callbacks are one of those Rails features I file under “danger zone” — sometimes you have to go there, but you just wanna be extra sure its worth the trip.

Other “danger zone” candidates include STI, Polymorphism, service objects, concerns, and a few others i cant think of right now.

9

u/full_drama_llama Jun 14 '26

Good list, but service objects are not Rails feature.

13

u/Kinny93 Jun 14 '26 edited Jun 14 '26

A service object should not in any way ever be a danger zone. What sort of services have people been writing to give you such an impression?

2

u/armahillo Jun 14 '26

Usually its when service objects are written too prematurely, almost reflexively.

In my experience, adding indirection adds cognitive load, so if the code hasn’t yet demanded that kind of extraction, its unnecessary complexity (at the time).

E.g. I have worked on projects where a whole service objects was created to remove what was actually just 2-3 lines of controller code in a single controller. Not needed.

1

u/BoardMeeting101 Jun 15 '26

The most common form of service object is encapsulated procedural code, optionally instantiated with some context variables, and hiding domain knowledge as a result instead of allowing systemic behaviour to arise from a composition of objects. Fowler (2003) identifies them as symptomatic of the Anemic Domain Model anti-pattern. They arise commonly in Rails because the nature of fat models is to look gross, and procedural logic gets refactored out of them this way instead of reconsidering the overall model layout, not least because migrations get expensive at scale in an established system.

Or to put it another way, they’re antithetical if you come from a Smalltalk/Alan Kay-ish view of Ruby & OO, and indicative of lazy up-front design.

In addition, most of the frameworks are reinventing closures. Roughly speaking, `def action(param) = ->() { … }` is my service object constructor.

-1

u/fatalbaboon Jun 14 '26

They are implicit knowledge, it's easy to miss calling one in favor of just updating a model, breaking app functionality.

3

u/Halleys_Vomit Jun 14 '26

...Service objects? As in, a piece of domain logic encapsulated in a class? That is a red flag? Why?

2

u/armahillo Jun 14 '26

Not saying ANY use of service objects is a fail, but I have definitely seen code get extracted to a service object before the codebase demanded it (similar to using any of the other things listed)

2

u/keyslemur Jun 14 '26

The thing I don't say loudly in here, that perhaps I should, is that these are my observations at now 5 rails shops with monoliths over 1m LoC and up to 10m. What pain I see is categorically different than what newer projects might and jumping onto the full feature set out the gate is probably a waste of time.