STI subclasses, no type column, and letting Rails be Rails.
Multi-step forms in Rails are one of those problems where the obvious approaches all have trade-offs that compound as the form grows. You start with something simple: four steps, each collecting a slice of one record. By the time you’ve got validation working per step, persistence sorted, and a way to resume halfway through, you’re looking at a lot of machinery for what should be a straightforward thing.
I’ve tried a few approaches to this over the years. The one I keep coming back to uses Ruby subclasses as validation namespaces, with STI’s persistence layer switched off. It sounds unusual, and it is. But it’s also the smallest, most Rails-like solution I’ve found.
The problem
Imagine you’re building a form that collects information across four steps. Each step gathers a different set of fields. You want to validate only the fields relevant to the current step, persist the answers as the user goes (so they can come back later), and run full validation at the end before publishing.
This is a common pattern. Onboarding flows, listing submissions, risk assessments, and application forms. The domain doesn’t matter much. The structural problem is the same: one record, multiple validation contexts, and a user who might abandon the process at any point.
The usual approaches
Form objects per step
The most common advice is to create a form object for each step. Something like Listing::LocationStep, Listing::DetailsStep, and so on. Each one has its own attribute declarations and validations. On submit, the form object writes selected attributes onto the underlying record.
This works, but it duplicates your attributes. The form object declares :title, :description, and so on, and so does the model. When you add a field, you add it in two places. When you rename one, you rename it in two places. The form objects also sit outside ActiveRecord, so you lose things like form_with model binding unless you add them back manually.
On smaller wizards this is fine. On anything with more than three or four steps, the duplication starts to feel like a tax on every change.
Conditional validations
The other approach is to keep everything on the model and gate each validation with a context or a predicate:
class Listing < ApplicationRecord
validates :location, presence: true, on: :step_location
validates :title, presence: true, on: :step_details
validates :title, presence: true, if: :past_step_details?
end
This keeps the attributes in one place, but the model fills up with conditional logic. Every validation has an on: or an if: attached to it. When you read the model, you’re parsing two things at once: what’s being validated, and when. On a model with twenty or thirty validations across five steps, that’s a lot of noise.
There’s also a subtler problem with both of these approaches: they diverge from how Rails normally handles forms and CRUD. In a typical Rails app, you have a model, a controller, and a view. The controller finds or creates the record, the model validates it, and the view renders the form. With form objects or heavily conditional models, the controller has to do extra work to figure out which step it’s on, which validations to apply, and where to redirect on failure. It stops looking like Rails.
The Redis approach
I’ve also seen implementations that store step answers in Redis or the session, then hydrate the record at the end. You fill in step one, the answers go into a cache. Step two, same. At the final step, everything gets pulled out and written to the database in one go.
The immediate problem is that your data is invisible. You can’t query it, you can’t inspect it easily, and if the cache expires or the user clears their session, the answers are gone. Debugging is harder too. When something goes wrong on step three, you’re reading data out of Redis rather than looking at a database row.
The mega-controller
Then there’s the pattern where a single controller manages all the steps. It works out which step you’re on from a parameter or the record’s state, builds the right form, applies the right validations, and redirects accordingly.
The problem here is discoverability. You look at a page in the browser, note the URL, and go looking for the controller. It’s not there, or rather, it’s buried inside a single controller that handles five different pages. When you need to change a label on step three, you have to trace through the step-switching logic to find the right branch. It works, but it’s hard to find anything.
The pattern: STI subclasses as validation namespaces
The approach I’ve settled on uses plain Ruby inheritance. Each step gets a subclass of the base model. The subclass declares only that step’s validations. Nothing else.
class Listing < ApplicationRecord
self.inheritance_column = nil
end
class Listing::Location < Listing
validates :location, presence: true
validates :region, presence: true
end
class Listing::Details < Listing
validates :title, presence: true
validates :description, presence: true
end
class Listing::Pricing < Listing
validates :price, numericality: { greater_than: 0 }
validates :currency, inclusion: { in: %w[GBP EUR USD] }
end
The key line is self.inheritance_column = nil on the base class. This tells Rails not to persist a type column, which is what STI normally does. Without it, Rails would write Listing::Location into a type column on the row, and every subsequent read would instantiate that subclass instead of the base Listing. We don’t want that. We want every row to come back as a plain Listing regardless of which subclass was used to write it.
Rails has supported this since at least version 5. It’s not a hack or a workaround. It’s a configuration option that tells ActiveRecord to skip class-based polymorphism on that table. The subclasses still inherit all the columns and associations from the parent. They just don’t drive the type column.
What the controllers look like
Each step gets its own controller. A normal, boring Rails controller.
class Listings::LocationsController < ApplicationController
def show
@listing = Listing::Location.find(params[:listing_id])
end
def update
@listing = Listing::Location.find(params[:listing_id])
if @listing.update(listing_params)
redirect_to listing_details_path(@listing)
else
render :show, status: :unprocessable_entity
end
end
private
def listing_params
params.expect(listing: [:location, :region])
end
end
That’s it. The controller finds the record using the step’s subclass, which means only that step’s validations run on update. If validation fails, it re-renders the form with errors. If it passes, it redirects to the next step. This is exactly how any Rails controller works for any model. There’s nothing wizard-specific about it.
The routes follow the same logic:
resources :listings, only: %i[index show new] do
resource :location, only: %i[show update]
resource :details, only: %i[show update]
resource :pricing, only: %i[show update]
resource :review, only: %i[show update]
end
Each step is a singular nested resource. The URLs read naturally: /listings/42/location, /listings/42/details, /listings/42/pricing, /listings/42/review. When you see a URL in the browser, you know exactly which controller to open.
The review step
The review step is where full validation happens. Its subclass declares all the validations that need to pass before the record can be published:
class Listing::Review < Listing
validates :location, presence: true
validates :region, presence: true
validates :title, presence: true
validates :description, presence: true
validates :price, numericality: { greater_than: 0 }
validates :currency, inclusion: { in: %w[GBP EUR USD] }
end
The review controller’s show action renders all the collected answers for confirmation. The update action runs full validation via the Review subclass, sets a published_at timestamp, and redirects to the index or show page.
If the user somehow reached the review step with missing data from an earlier step (maybe they manually edited the URL), the review validation catches it. The full set of validations acts as a safety net.
Starting the wizard
The listings#new action creates the record upfront:
class ListingsController < ApplicationController
def new
@listing = Listing.create!
redirect_to listing_location_path(@listing)
end
end
The record exists in the database from the very first step. Each subsequent step updates the same row. There’s no cache, no session state, and no hydration at the end. The data is always in the database, always queryable, and always inspectable.
If you need “resume” functionality, you look for an unpublished record and redirect to whatever step is incomplete. The database has everything you need to work that out.
Why this works
The reason I keep coming back to this pattern is that it doesn’t fight Rails. Every step looks like a normal CRUD operation. The controllers are standard. The views use form_with model: @listing. The routes are RESTful. There’s no step-tracking state machine, no form object layer, and no conditional validation predicates.
Adding a new step is also straightforward. You create a subclass with the new step’s validations, add a controller, add a view, add the route, and wire up the redirects. Each piece is in the place you’d expect it to be.
The subclasses themselves are intentionally minimal. They’re just validation declarations. No methods, no scopes, no callbacks. If you find yourself adding step-specific logic to a subclass, that logic probably belongs on the base class or in a service object. The subclasses are namespaces, not models.
The trade-offs
This pattern is unusual, and that’s the main cost. A developer seeing Listing::Location < Listing for the first time will probably assume STI is in play and go looking for a type column. When they don’t find one, they’ll be confused. This is a pattern that benefits from an ADR or a comment at the top of the base class explaining what’s going on and why.
The other thing to watch is inheritance. The subclasses inherit everything from the parent, including validations. If someone adds an unconditional validation to the base Listing class, every step subclass inherits it, and every step’s form will fail validation on fields that step doesn’t even collect. In practice, I haven’t been bitten by this. The per-step specs and the wizard’s request specs catch it quickly. But it’s worth knowing about, especially on a team where multiple people touch the model.
Keep the subclasses thin. Resist adding per-step methods or callbacks. The moment a subclass does more than declare validations, you’re building a parallel model hierarchy, and that’s a different kind of complexity.
When to reach for this
This pattern works well when you have a linear wizard collecting data for a single record, each step has a distinct set of fields, the data should be persisted as the user progresses, and the steps are unlikely to need complex branching logic.
It works less well for wizards where the steps change based on previous answers, where the data spans multiple models, or where you need to support arbitrary step ordering. For those, a state machine or a more explicit workflow object is probably the better fit.
For the straightforward case, though, where you just need per-step validation on a single record without building a whole framework around it, this is the simplest thing I’ve found. A few subclasses, a disabled inheritance column, and Rails does the rest.