Single Table Inheritance in Ruby on Rails with PostgreSQL
Single Table Inheritance (STI) is a powerful design pattern in Ruby on Rails that allows you to store different types of related models in a single database table. This approach is particularly useful when you have a hierarchy of classes that share common attributes but have different behaviors.
What is Single Table Inheritance?
STI is a database design pattern where multiple related classes are stored in one table, with a special column (typically called type) that identifies which class each record belongs to. This pattern is ideal when you have models that share most of their attributes but differ in behavior or have a few specialized fields.
When to Use STI
STI works best when:
- Your models share 80% or more of their attributes
- The differences are primarily behavioral rather than structural
- You need to query across all types frequently
- The hierarchy is relatively simple and stable
Common use cases include:
- User types (Admin, Customer, Employee)
- Product categories (Book, Electronics, Clothing)
- Payment methods (CreditCard, PayPal, BankTransfer)
- Content types (Article, Video, Podcast)
Setting Up STI in Rails with PostgreSQL
Let's walk through implementing STI with a practical example of different user types.
Step 1: Generate the Base Model
First, create a migration for the base model:
rails generate model User name:string email:string type:string role:string
Step 2: Update the Migration
Edit the generated migration to ensure proper indexing:
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :name, null: false
t.string :email, null: false
t.string :type, null: false
t.string :role
t.timestamps
end
add_index :users, :type
add_index :users, :email, unique: true
end
end
Step 3: Create the Base Model
# app/models/user.rb
class User < ApplicationRecord
validates :name, presence: true
validates :email, presence: true, uniqueness: true
# Common behavior for all user types
def full_name
name.titleize
end
def active?
# Common logic
true
end
end
Step 4: Create Subclasses
Now create the specific user types:
# app/models/admin.rb
class Admin < User
validates :role, inclusion: { in: %w[super_admin admin moderator] }
def can_manage_users?
role.in?(%w[super_admin admin])
end
def dashboard_path
'/admin/dashboard'
end
end
# app/models/customer.rb
class Customer < User
has_many :orders, dependent: :destroy
def total_orders
orders.count
end
def dashboard_path
'/customer/dashboard'
end
end
# app/models/employee.rb
class Employee < User
validates :role, inclusion: { in: %w[manager supervisor staff] }
def can_access_reports?
role.in?(%w[manager supervisor])
end
def dashboard_path
'/employee/dashboard'
end
end
Working with STI Models
Creating Records
# Create different types of users
admin = Admin.create!(name: "John Doe", email: "john@example.com", role: "admin")
customer = Customer.create!(name: "Jane Smith", email: "jane@example.com")
employee = Employee.create!(name: "Bob Johnson", email: "bob@example.com", role: "staff")
Querying Records
# Get all users
User.all
# Get specific types
Admin.all
Customer.all
Employee.all
# Query with conditions
User.where(name: "John")
Admin.where(role: "admin")
# Check type
user = User.find(1)
user.is_a?(Admin) # true/false
user.type # "Admin"
Polymorphic Associations
STI works seamlessly with associations:
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :user # This can be any User subclass
def customer
user if user.is_a?(Customer)
end
end
# Usage
order = Order.create!(user: Customer.first, total: 100.0)
order.user.class # Customer
Advanced STI Techniques
Custom Type Column
You can customize the type column name:
class User < ApplicationRecord
self.inheritance_column = 'user_type'
end
Disabling STI
If you need to disable STI for a specific query:
class User < ApplicationRecord
self.inheritance_column = nil # Disables STI
end
STI with Scopes
Add scopes to make querying easier:
class User < ApplicationRecord
scope :admins, -> { where(type: 'Admin') }
scope :customers, -> { where(type: 'Customer') }
scope :employees, -> { where(type: 'Employee') }
def self.by_type(type)
where(type: type.to_s.classify)
end
end
PostgreSQL Specific Considerations
Using PostgreSQL Features
Take advantage of PostgreSQL's advanced features:
# Using JSONB for type-specific data
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :name, null: false
t.string :email, null: false
t.string :type, null: false
t.jsonb :metadata, default: {}
t.timestamps
end
add_index :users, :type
add_index :users, :metadata, using: :gin
end
end
# Using metadata in models
class Admin < User
def permissions
metadata['permissions'] || []
end
def add_permission(permission)
self.metadata = metadata.merge('permissions' => permissions + [permission])
end
end
Partial Indexes
Create partial indexes for better performance:
# In migration
add_index :users, :email, where: "type = 'Customer'", unique: true
add_index :users, :role, where: "type IN ('Admin', 'Employee')"
Best Practices
1. Keep the Hierarchy Simple
Avoid deep inheritance hierarchies. STI works best with 2-3 levels maximum.
2. Validate the Type Column
Always validate the type column to prevent invalid data:
class User < ApplicationRecord
validates :type, inclusion: { in: %w[Admin Customer Employee] }
end
3. Use Factories for Testing
Create factories for each subclass:
# spec/factories/users.rb
FactoryBot.define do
factory :user do
name { "John Doe" }
email { "john@example.com" }
factory :admin do
type { "Admin" }
role { "admin" }
end
factory :customer do
type { "Customer" }
end
factory :employee do
type { "Employee" }
role { "staff" }
end
end
end
4. Handle Type Conversion Carefully
Be cautious when changing types:
class User < ApplicationRecord
def change_type_to(new_type)
return false unless new_type.in?(%w[Admin Customer Employee])
self.type = new_type
# Clear type-specific attributes if needed
self.role = nil unless new_type.in?(%w[Admin Employee])
save
end
end
Common Pitfalls to Avoid
1. Too Many Type-Specific Columns
If you find yourself adding many columns that are only used by certain types, consider using separate tables instead.
2. Complex Validations
Avoid complex conditional validations based on type. Keep validations simple and move complex logic to the subclasses.
3. Ignoring NULL Values
Many columns will be NULL for certain types. Design your application to handle this gracefully.
4. Forgetting Indexes
Always add indexes on the type column and any frequently queried columns.
Testing STI Models
# spec/models/user_spec.rb
RSpec.describe User, type: :model do
describe "STI behavior" do
it "creates the correct subclass" do
admin = Admin.create!(name: "Admin", email: "admin@test.com", role: "admin")
expect(admin.class).to eq(Admin)
expect(admin.type).to eq("Admin")
end
it "queries subclasses correctly" do
admin = create(:admin)
customer = create(:customer)
expect(Admin.all).to include(admin)
expect(Admin.all).not_to include(customer)
expect(User.all).to include(admin, customer)
end
end
end
Performance Considerations
Query Optimization
# Use includes for associations
User.includes(:orders).where(type: 'Customer')
# Use specific subclass queries when possible
Customer.includes(:orders) # More efficient than User.where(type: 'Customer')
Database Indexes
-- Essential indexes for STI
CREATE INDEX CONCURRENTLY idx_users_type ON users (type);
CREATE INDEX CONCURRENTLY idx_users_type_email ON users (type, email);
Conclusion
Single Table Inheritance is a powerful pattern in Rails that can simplify your data model when you have related classes with similar attributes but different behaviors. When combined with PostgreSQL's advanced features like JSONB and partial indexes, STI becomes even more powerful.
Remember to use STI judiciously – it's not always the right solution. Consider the alternatives like polymorphic associations or separate tables when your models diverge significantly in their attributes or when the hierarchy becomes complex.
The key to successful STI implementation is keeping the design simple, maintaining proper indexes, and ensuring your application gracefully handles the NULL values that are inherent in this pattern.