ActiveRecord Medium severity

STI Abuse

Using Rails Single Table Inheritance to model subclasses with divergent attribute sets, producing one wide table where most columns are NULL for any given row and unique constraints become impossible to enforce per subclass.

Before / After

Problematic Pattern
# One users table with 40+ columns.
# type column is the discriminator.

class User < ApplicationRecord
end

class Customer < User
# uses billing_address, stripe_id, plan_tier
end

class Admin < User
# uses permission_level, mfa_secret
end

class Vendor < User
# uses tax_id, iban, vat_rate, commission_rate
end

# Most rows have 70% NULL columns.
Target Architecture
# Rails 6.1+ Delegated Type
class Participant < ApplicationRecord
delegated_type :participantable,
  types: %w[Customer Admin Vendor]
end

module Participantable
extend ActiveSupport::Concern
included do
  has_one :participant, as: :participantable
end
end

class Customer < ApplicationRecord
include Participantable
# only Customer columns
end

class Admin < ApplicationRecord
include Participantable
# only Admin columns, MFA required
end

Why this hurts

PostgreSQL stores NULL values as a bitmap in the row header, which is storage-efficient, but every sequential scan still reads the full row including all the fixed-width column slots. On a table with 40 columns where 28 are NULL for a given subclass, the database transfers the complete row into memory and the planner spends CPU on type-casting empty values. Buffer cache pressure increases because each 8 KB page holds fewer useful rows; the working set of hot data that actually fits in RAM shrinks, triggering more disk reads under normal load.

Index selectivity degrades across the board. A partial index on stripe_id would be useful for Customer lookups, but filtering must include WHERE type = 'Customer' or the index includes NULL entries for Admins and Vendors, bloating the B-tree and lowering cache hit rates. EXPLAIN ANALYZE shows the planner choosing sequential scans over index scans when pg_statistic says the column has 70% NULL values, because the planner reasonably estimates that few rows will satisfy the predicate.

Schema evolution becomes dangerous. ALTER TABLE users ADD COLUMN tax_jurisdiction text locks the table for a metadata update on PostgreSQL 11+, but adding a new subclass that needs five new columns requires five sequential ALTER TABLEs, and each one still needs coordination across replicas and connection pools. Foreign keys cannot be scoped per subclass, so a Vendor can accidentally reference a Customer-only entity without any database-level protection. Validations in the single class branch on type, scattering business rules across unrelated contexts. Type-checking tools like Sorbet or RBS struggle because the model’s attributes depend on runtime type data rather than class shape.

Get Expert Help

Inheriting a legacy Rails codebase with this problem? Request a Technical Debt Audit.