Skip to main content

Command Palette

Search for a command to run...

A Pattern for STIs in Rails

Updated
3 min read
X

Everlasting student · Rails Core · Zeitwerk · Freelance · Life lover

The Problem

Active Record provides STIs to easily work with hierarchies of (persisted) models.

They have a few rough edges, however:

  1. Active Record needs to know the entire hierarchy to be able to build correct queries. This is at odds with lazy loading.

  2. You open app/models and don't see the STI anywhere. It is all flat and mixed.

There have been some proposals to lessen the friction in (1) but, as of today, unfortunately, none that personally fully satisfies me.

One Possible Pattern for STIs

The gist of the idea is:

  1. Group related files using Zeitwerk's collapsing feature.

  2. Eager load those distinguished directories on boot and reload.

I'll explain the technique using a simple example first.

Let's suppose we have Vehicle, Truck, Car, Motorbike. Instead of having them directly under app/models, we create a directory with a meaningful name that groups them:

app/models/user.rb
app/models/product.rb
app/models/vehicles/car.rb
app/models/vehicles/motorbike.rb
app/models/vehicles/truck.rb
app/models/vehicles/vehicle.rb

Here, app/models/vehicles is not a namespace, app/models/vehicles/car.rb still defines a top-level Car class. The sole purpose of that directory is to help us organize the code. So, when a maintainer wonders which models conform to the hierarchy, they go to that folder and it is all grouped.

Now, as an extra ball, since the STI is all in one place, we have a target to easily eager load.

So, we could have this initializer:

# config/initializers/preoload_vehicles.rb

vehicles = "#{Rails.root}/app/models/vehicles"
Rails.autoloaders.main.collapse(vehicles)
Rails.application.config.to_prepare do
  Rails.autoloaders.main.eager_load_dir(vehicles)
end

The method eager_load_dir needs Zeitwerk 2.6.2. If you are running an older version of Zeitwerk, please just list the contents of the directory and issue individual require_dependency calls.

Maintenance

The section above shows the core technique. From here, you can customize your own STI preloader pattern to your liking.

If you add a new model to the STI, it is going to be picked up automatically. However, if you add a new STI, you need to edit that initializer to also preload the new STI, and reboot. This is a rare event, and might be a good-enough trade-off, because things are clear and simple to understand.

Or, you could adopt a name convention that generalizes the technique. For example, to have a _sti suffix that marks those directories.

This would tell maintainers looking at app/models those directories store STIs, and the preloader would just dynamically iterate over all directories with that suffix. In this option, there is zero maintenance, you can add models and STIs, and all will be correctly eager loaded on boot and reboot.

You see, from the core pattern, you can evolve to find your sweet spot.

This is not Lazy

With this technique, we load the STI models always, even if they are not used. Of course, we are in a conflicting situation and this is a trade-off. But I don't think this matters in practice.

Could we go an extra mile and delay eager loading until some model in the hierarchy is loaded? Perhaps. Maybe Rails includes something like this in the future if we find an even better technique, we'll see.

However, this pattern is simple, explicit, and easy to understand. When a newly created Rails 7 application boots, it loads some 1700 Ruby files, I believe a few extra ones won't make a measurable difference anyway.

C
chaadow3y ago

Could we go an extra mile and delay eager loading until some model in the hierarchy is loaded? Perhaps. Maybe Rails includes something like this in the future if we find an even better technique, we'll see.

Can't we use something like Rails.autoloaders.main.on_load('MyModel') { ... } ?

This can be useful when applying the strategy pattern, in the same vein as having a _sti as a suffi, a team can use a xxxBase suffix.

Which makes me want to ask you this question: Can we use a regular expression with the on_load hook?. This in combination with the _sti prefix idea, we can eager load a directory if it exists.

Basically the Base class will act as a router, look through the descendants and return the first applicable strategy class ( like ActiveJob::Serializers does )

Do you think this is feasible? Thanks for the article!

X

chaadow Sorry, I got no notification for these comments!

That is a good idea and a version of that was documented. Problem is that it may lead to unsolvable circular dependencies that depend on load order. I am revamping the section in the guides about this topic.

D

To confirm, whilst this idea won't auto discover new subclasses, it will auto-reload changes to any of the STI class files?

X

Dr Nic Williams Sorry, got no notification for your comment!

Yes, these classes will be reloaded when the application reloads, and the files watched too.