A Pattern for STIs in Rails

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.