Jhumur Chatterjee

Fullstack Developer

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.