Polymorphism vs. STI: Architectural Lessons from a Rails Apprentice
November 29, 2018 · 3 min read
Update — April 4, 2026: This post has been updated to improve clarity and structure. Key changes include enhanced explanations of Active Record associations, improved code block readability, and a more focused comparison between Polymorphism and STI.
I’m currently nearing the end of a project I’ve been working on for the past three and a half months—my first real Rails application and my first professional project at Pernix. It has been an incredible experience learning the nuances of the framework and building confidence with the ORM.
While I had done the classic "hello world" blog before, real-world modeling presented new challenges. Specifically, managing complex user roles with custom data was a significant hurdle.
The Challenge: Beyond Basic Roles
In many projects, you can handle roles with a simple enum in the User model. However, in this project, every role (Admin, Mentor, Client) required its own unique set of attributes. Creating separate models entirely felt wrong because they still shared core user data.
Initial Approach: Polymorphic Associations
My first solution was to use a polymorphic association. This allowed the User model to belong to an "account" that could be any of the role types.
class User < ApplicationRecord
belongs_to :account, polymorphic: true, dependent: :destroy
end
class Admin < ApplicationRecord
has_one :user, as: :account
end
class Mentor < ApplicationRecord
has_one :user, as: :account
end
class Client < ApplicationRecord
has_one :user, as: :account
end
To make this work, I added account_id and account_type columns to the users table. While Rails convention often uses the -able suffix (like accountable), I felt account was more descriptive in this specific context.
Querying the role was simple:
user.account # Returns the Admin, Client, or Mentor object
admin.user # Returns the base User attributes
The Pivot: Single Table Inheritance (STI)
A new project leader later refactored this logic to use Single Table Inheritance (STI). Although I was initially attached to my polymorphic solution, STI proved to be cleaner and more intuitive for our needs.
How STI Differs
In STI, all roles are stored in a single table (the users table), differentiated by a type column. Unlike polymorphism, where every model inherits from ApplicationRecord, STI models inherit directly from the base User class.
class User < ApplicationRecord
end
class Admin < User
end
class Mentor < User
end
class Client < User
end
The primary benefit is that the object is the role. Calling user returns the specific subclass object, and you don't need to traverse an association to find role-specific logic.
Solving the "Fat Table" Problem
The main criticism of STI is that the shared table can become cluttered with columns that only apply to one specific role. To solve this, we used a has_one association for custom role data.
For example, the Client model (which inherits from User) has an association with ClientInfo:
class Client < User
has_one :client_info, dependent: :destroy
end
class ClientInfo < ApplicationRecord
belongs_to :client, dependent: :destroy
end
This hybrid approach gives us the clean API of STI while keeping the database schema normalized and performant.
Final Thoughts
Understanding these associations is critical for anyone using an ORM. Whether you choose Polymorphism or STI depends on your data structure, but knowing how to manipulate that data properly makes the difference between a brittle app and a scalable one.