Multiple Table Inheritance Patterns in Ruby on Rails with PostgreSQL
Multiple Table Inheritance (MTI) is a database design pattern where related classes are stored across multiple tables, with each subclass having its own table that references a common base table. This approach provides better data normalization and type safety compared to Single Table Inheritance (STI) but comes with increased complexity.
What is Multiple Table Inheritance?
MTI splits inheritance hierarchies across multiple tables, where:
- A base table contains common attributes
- Each subclass has its own table with specific attributes
- Subclass tables reference the base table through foreign keys
- Rails models use associations to connect the tables
This pattern is also known as "Class Table Inheritance" in database design literature.
When to Use MTI
MTI is ideal when:
- Subclasses have significantly different attributes (less than 60% overlap)
- You need strong data consistency and type safety
- NULL values in STI would be excessive
- You want to enforce referential integrity
- Performance of specific subclass queries is critical
- You need to add indexes specific to each subclass
MTI Implementation Patterns
Pattern 1: Polymorphic Associations (Rails Way)
This is the most Rails-idiomatic approach using polymorphic associations.
Step 1: Create the Base Model
rails generate model Profile name:string email:string profileable:references{polymorphic}
Step 2: Create Subclass Models
rails generate model CustomerProfile phone:string address:text loyalty_points:integer
rails generate model EmployeeProfile employee_id:string department:string salary:decimal
rails generate model AdminProfile access_level:string last_login:datetime permissions:text
Step 3: Set Up Migrations
# db/migrate/create_profiles.rb
class CreateProfiles < ActiveRecord::Migration[7.0]
def change
create_table :profiles do |t|
t.string :name, null: false
t.string :email, null: false
t.references :profileable, polymorphic: true, null: false
t.timestamps
end
add_index :profiles, [:profileable_type, :profileable_id]
add_index :profiles, :email, unique: true
end
end
# db/migrate/create_customer_profiles.rb
class CreateCustomerProfiles < ActiveRecord::Migration[7.0]
def change
create_table :customer_profiles do |t|
t.string :phone
t.text :address
t.integer :loyalty_points, default: 0
t.timestamps
end
add_index :customer_profiles, :loyalty_points
end
end
# db/migrate/create_employee_profiles.rb
class CreateEmployeeProfiles < ActiveRecord::Migration[7.0]
def change
create_table :employee_profiles do |t|
t.string :employee_id, null: false
t.string :department
t.decimal :salary, precision: 10, scale: 2
t.timestamps
end
add_index :employee_profiles, :employee_id, unique: true
add_index :employee_profiles, :department
end
end
Step 4: Set Up Models
# app/models/profile.rb
class Profile < ApplicationRecord
belongs_to :profileable, polymorphic: true
validates :name, presence: true
validates :email, presence: true, uniqueness: true
# Delegate methods to profileable
delegate :specific_info, :dashboard_path, to: :profileable
def full_name
name.titleize
end
def profile_type
profileable_type.underscore.humanize
end
end
# app/models/customer_profile.rb
class CustomerProfile < ApplicationRecord
has_one :profile, as: :profileable, dependent: :destroy
has_many :orders, dependent: :destroy
validates :phone, format: { with: /\A[\d\-\s\+\(\)]+\z/ }
validates :loyalty_points, numericality: { greater_than_or_equal_to: 0 }
def specific_info
"Loyalty Points: #{loyalty_points}"
end
def dashboard_path
'/customer/dashboard'
end
def add_loyalty_points(points)
increment(:loyalty_points, points)
save
end
end
# app/models/employee_profile.rb
class EmployeeProfile < ApplicationRecord
has_one :profile, as: :profileable, dependent: :destroy
validates :employee_id, presence: true, uniqueness: true
validates :department, presence: true
validates :salary, numericality: { greater_than: 0 }
DEPARTMENTS = %w[Engineering Sales Marketing HR Finance].freeze
validates :department, inclusion: { in: DEPARTMENTS }
def specific_info
"Department: #{department}, Salary: $#{salary}"
end
def dashboard_path
'/employee/dashboard'
end
def annual_salary
salary * 12
end
end
# app/models/admin_profile.rb
class AdminProfile < ApplicationRecord
has_one :profile, as: :profileable, dependent: :destroy
validates :access_level, inclusion: { in: %w[super_admin admin moderator] }
def specific_info
"Access Level: #{access_level}"
end
def dashboard_path
'/admin/dashboard'
end
def permissions_list
permissions.split(',').map(&:strip)
end
def can_manage_users?
access_level.in?(%w[super_admin admin])
end
end
Step 5: Factory Pattern for Creation
# app/services/profile_factory.rb
class ProfileFactory
def self.create_customer(attributes)
ActiveRecord::Base.transaction do
customer = CustomerProfile.create!(
phone: attributes[:phone],
address: attributes[:address],
loyalty_points: attributes[:loyalty_points] || 0
)
profile = Profile.create!(
name: attributes[:name],
email: attributes[:email],
profileable: customer
)
profile
end
end
def self.create_employee(attributes)
ActiveRecord::Base.transaction do
employee = EmployeeProfile.create!(
employee_id: attributes[:employee_id],
department: attributes[:department],
salary: attributes[:salary]
)
profile = Profile.create!(
name: attributes[:name],
email: attributes[:email],
profileable: employee
)
profile
end
end
def self.create_admin(attributes)
ActiveRecord::Base.transaction do
admin = AdminProfile.create!(
access_level: attributes[:access_level],
permissions: attributes[:permissions]
)
profile = Profile.create!(
name: attributes[:name],
email: attributes[:email],
profileable: admin
)
profile
end
end
end
Pattern 2: Shared Primary Key (Foreign Key Inheritance)
This pattern uses the same primary key across all tables.
Step 1: Create Base Table
# db/migrate/create_users.rb
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.timestamps
end
add_index :users, :email, unique: true
add_index :users, :type
end
end
Step 2: Create Subclass Tables
# db/migrate/create_customers.rb
class CreateCustomers < ActiveRecord::Migration[7.0]
def change
create_table :customers, id: false do |t|
t.references :user, foreign_key: true, primary_key: true
t.string :phone
t.text :address
t.integer :loyalty_points, default: 0
t.timestamps
end
add_index :customers, :loyalty_points
end
end
# db/migrate/create_employees.rb
class CreateEmployees < ActiveRecord::Migration[7.0]
def change
create_table :employees, id: false do |t|
t.references :user, foreign_key: true, primary_key: true
t.string :employee_id, null: false
t.string :department
t.decimal :salary, precision: 10, scale: 2
t.timestamps
end
add_index :employees, :employee_id, unique: true
add_index :employees, :department
end
end
Step 3: Set Up Models
# app/models/user.rb
class User < ApplicationRecord
has_one :customer, dependent: :destroy
has_one :employee, dependent: :destroy
has_one :admin, dependent: :destroy
validates :name, presence: true
validates :email, presence: true, uniqueness: true
validates :type, inclusion: { in: %w[Customer Employee Admin] }
def profile
case type
when 'Customer' then customer
when 'Employee' then employee
when 'Admin' then admin
end
end
def specific_info
profile&.specific_info
end
end
# app/models/customer.rb
class Customer < ApplicationRecord
belongs_to :user
has_many :orders, dependent: :destroy
validates :phone, format: { with: /\A[\d\-\s\+\(\)]+\z/ }
validates :loyalty_points, numericality: { greater_than_or_equal_to: 0 }
delegate :name, :email, to: :user
def specific_info
"Loyalty Points: #{loyalty_points}"
end
def dashboard_path
'/customer/dashboard'
end
end
Pattern 3: PostgreSQL Table Inheritance
PostgreSQL supports native table inheritance, which Rails can leverage.
Step 1: Create Base Table with Inheritance
-- This needs to be done via raw SQL in migration
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
email VARCHAR NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE customers (
phone VARCHAR,
address TEXT,
loyalty_points INTEGER DEFAULT 0
) INHERITS (users);
CREATE TABLE employees (
employee_id VARCHAR NOT NULL UNIQUE,
department VARCHAR,
salary DECIMAL(10,2)
) INHERITS (users);
Step 2: Rails Migration
class CreateInheritedTables < ActiveRecord::Migration[7.0]
def up
execute <<-SQL
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
email VARCHAR NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE customers (
phone VARCHAR,
address TEXT,
loyalty_points INTEGER DEFAULT 0
) INHERITS (users);
CREATE TABLE employees (
employee_id VARCHAR NOT NULL UNIQUE,
department VARCHAR,
salary DECIMAL(10,2)
) INHERITS (users);
SQL
end
def down
drop_table :customers
drop_table :employees
drop_table :users
end
end
Step 3: Configure Models
# app/models/user.rb
class User < ApplicationRecord
self.table_name = 'users'
def self.all_including_inherited
find_by_sql("SELECT * FROM users")
end
end
# app/models/customer.rb
class Customer < ApplicationRecord
self.table_name = 'customers'
def self.with_base_data
find_by_sql("SELECT * FROM customers")
end
end
# app/models/employee.rb
class Employee < ApplicationRecord
self.table_name = 'employees'
def self.with_base_data
find_by_sql("SELECT * FROM employees")
end
end
Advanced MTI Patterns
Pattern 4: Concern-Based Approach
Create shared behavior using concerns:
# app/models/concerns/profile_behavior.rb
module ProfileBehavior
extend ActiveSupport::Concern
included do
validates :name, presence: true
validates :email, presence: true, uniqueness: true
scope :by_name, ->(name) { where(name: name) }
scope :recent, -> { where('created_at > ?', 1.week.ago) }
end
def full_name
name.titleize
end
def initials
name.split.map(&:first).join.upcase
end
end
# app/models/customer_profile.rb
class CustomerProfile < ApplicationRecord
include ProfileBehavior
# Customer-specific behavior
end
Pattern 5: View-Based Queries
Use PostgreSQL views for complex queries:
# db/migrate/create_unified_profile_view.rb
class CreateUnifiedProfileView < ActiveRecord::Migration[7.0]
def up
execute <<-SQL
CREATE VIEW unified_profiles AS
SELECT
p.id,
p.name,
p.email,
p.profileable_type as type,
p.profileable_id,
p.created_at,
p.updated_at,
CASE
WHEN p.profileable_type = 'CustomerProfile' THEN cp.phone
WHEN p.profileable_type = 'EmployeeProfile' THEN ep.employee_id
ELSE NULL
END as identifier
FROM profiles p
LEFT JOIN customer_profiles cp ON p.profileable_id = cp.id AND p.profileable_type = 'CustomerProfile'
LEFT JOIN employee_profiles ep ON p.profileable_id = ep.id AND p.profileable_type = 'EmployeeProfile'
LEFT JOIN admin_profiles ap ON p.profileable_id = ap.id AND p.profileable_type = 'AdminProfile';
SQL
end
def down
execute "DROP VIEW unified_profiles"
end
end
# app/models/unified_profile.rb
class UnifiedProfile < ApplicationRecord
self.table_name = 'unified_profiles'
def readonly?
true
end
end
Querying MTI Models
Basic Queries
# Polymorphic approach
profiles = Profile.includes(:profileable)
customers = Profile.joins(:profileable).where(profileable_type: 'CustomerProfile')
# Shared primary key approach
all_users = User.includes(:customer, :employee, :admin)
customers = User.joins(:customer).where(type: 'Customer')
Complex Queries
# Find all profiles with specific attributes
high_loyalty_customers = Profile.joins(:profileable)
.where(profileable_type: 'CustomerProfile')
.where('customer_profiles.loyalty_points > ?', 1000)
# Cross-type queries
recent_profiles = Profile.includes(:profileable)
.where('created_at > ?', 1.month.ago)
.map { |p| { name: p.name, type: p.profileable_type, info: p.specific_info } }
Performance Optimization
# Use specific subclass queries when possible
CustomerProfile.includes(:profile).where('profiles.created_at > ?', 1.week.ago)
# Batch loading for polymorphic associations
profiles = Profile.includes(:profileable).limit(100)
Profile.preload_polymorphic_association(profiles, :profileable)
Testing MTI Models
RSpec Examples
# spec/models/profile_spec.rb
RSpec.describe Profile, type: :model do
describe "polymorphic associations" do
it "works with customer profiles" do
customer = create(:customer_profile)
profile = create(:profile, profileable: customer)
expect(profile.profileable).to eq(customer)
expect(profile.profileable_type).to eq('CustomerProfile')
end
it "delegates methods correctly" do
customer = create(:customer_profile, loyalty_points: 100)
profile = create(:profile, profileable: customer)
expect(profile.specific_info).to eq("Loyalty Points: 100")
end
end
end
Factory Setup
# spec/factories/profiles.rb
FactoryBot.define do
factory :profile do
name { "John Doe" }
email { "john@example.com" }
trait :with_customer do
association :profileable, factory: :customer_profile
end
trait :with_employee do
association :profileable, factory: :employee_profile
end
end
factory :customer_profile do
phone { "555-1234" }
address { "123 Main St" }
loyalty_points { 0 }
end
factory :employee_profile do
employee_id { "EMP001" }
department { "Engineering" }
salary { 75000 }
end
end
Performance Considerations
Indexing Strategies
# Polymorphic indexes
add_index :profiles, [:profileable_type, :profileable_id]
add_index :profiles, [:profileable_type, :email]
# Subclass-specific indexes
add_index :customer_profiles, :loyalty_points
add_index :employee_profiles, [:department, :salary]
Query Optimization
# Use eager loading
Profile.includes(:profileable).limit(10)
# Use joins for filtering
Profile.joins(:profileable).where(profileable_type: 'CustomerProfile')
# Use specific model queries
CustomerProfile.includes(:profile).where('profiles.created_at > ?', 1.week.ago)
Comparison: MTI vs STI
| Aspect | MTI | STI | | --------------------- | --------------- | ------------------- | | Data Normalization | Excellent | Poor | | Query Performance | Good (specific) | Excellent (general) | | Storage Efficiency | Excellent | Poor (many NULLs) | | Complexity | High | Low | | Flexibility | High | Medium | | Referential Integrity | Excellent | Good |
Best Practices
1. Choose the Right Pattern
- Polymorphic: Most flexible, Rails-idiomatic
- Shared PK: Better performance, more complex
- PostgreSQL Inheritance: Database-level benefits, Rails complexity
2. Optimize Queries
# Bad: N+1 queries
profiles = Profile.all
profiles.each { |p| puts p.profileable.specific_info }
# Good: Eager loading
profiles = Profile.includes(:profileable)
profiles.each { |p| puts p.profileable.specific_info }
3. Use Transactions
def create_customer_profile(attributes)
ActiveRecord::Base.transaction do
customer = CustomerProfile.create!(customer_attributes)
Profile.create!(profile_attributes.merge(profileable: customer))
end
end
4. Handle Associations Carefully
# Good: Proper cleanup
class CustomerProfile < ApplicationRecord
has_one :profile, as: :profileable, dependent: :destroy
end
# Good: Cascade deletes
class Profile < ApplicationRecord
belongs_to :profileable, polymorphic: true, dependent: :destroy
end
Common Pitfalls
1. Forgetting Indexes
Always index polymorphic columns and foreign keys.
2. N+1 Query Problems
Use includes for polymorphic associations.
3. Transaction Management
Wrap multi-table operations in transactions.
4. Inconsistent Data
Implement proper validations and constraints.
Conclusion
Multiple Table Inheritance provides excellent data normalization and type safety at the cost of increased complexity. The choice between different MTI patterns depends on your specific requirements:
- Use Polymorphic Associations for maximum Rails compatibility
- Use Shared Primary Key for better performance with increased complexity
- Use PostgreSQL Inheritance for database-level benefits
MTI is ideal when subclasses have significantly different attributes and you need strong data consistency. While more complex than STI, it provides better long-term maintainability for diverse inheritance hierarchies.
The key to successful MTI implementation is careful planning of your table structure, proper indexing, and consistent use of transactions for multi-table operations.