RUBY ON RAILS · 21 MIN READ ·

Rails Delegated Types: Single Table Inheritance Vervangen Zonder de Rommel

Rails delegated types uitgelegd: vervang STI met per-type tabellen. Migratiestrategie, querypatronen, N+1-valkuilen en wanneer STI nog de juiste keuze is.

Rails Delegated Types: Single Table Inheritance Vervangen Zonder de Rommel

Het model heette Notification. Toen ik voor het eerst het schema opende, telde de tabel 52 kolommen. De kolom type had acht waarden in productie: EmailNotification, SmsNotification, PushNotification, InAppNotification, SlackNotification, WebhookNotification, DigestNotification en SystemNotification. Van die 52 kolommen gebruikte elke rij er hooguit 14. De overige 38 waren NULL. Het modelbestand was 430 regels case type-vertakkingen, callbacks gewikkeld in if email_notification?, en scopes die stilletjes kapotgingen zodra iemand een negende type toevoegde zonder elke vertakking bij te werken.

Het team had gegrepen naar Single Table Inheritance omdat het het eerste is wat Rails aanbiedt als je meerdere varianten van één concept nodig hebt. STI is niet fout — het is de juiste keuze voor een beperkte set problemen. Maar het heeft een bekende faalvorm, en die codebase leefde er volop in.

Na negentien jaar Rails heb ik dit patroon echte schade zien aanrichten: opgeblazen tabellen, verwarde queries en modellen die in de praktijk onmogelijk te doorgronden zijn. Rails 6.1 leverde een eersteklas antwoord: Rails delegated types. Het is een van de meest ondergebruikte features in modern Rails, en dit artikel is de praktische gids die ik teams geef als ze tegen de STI-muur lopen.

Wat STI Doet en Waar Het Breekt

Single Table Inheritance slaat elke variant van een type op in één tabel, met een type-stringkolom die Rails gebruikt om te beslissen welke subklasse het moet instantiëren. De aantrekkingskracht is voor de hand liggend: één migratie, één tabel, en het volledige bereik van polymorfe Rails-queries werkt gewoon.

De faalvorm heeft twee gedaantes.

De eerste is de null-kolomexplosie. Elk type-specifiek attribuut moet in de gedeelde tabel staan, zodat kolommen die alleen van toepassing zijn op één of twee typen als NULL eindigen voor elk ander type. Een brede tabel met veel schaarse kolommen neemt daadwerkelijk schijfruimte in beslag in de dead-tuple heap en voegt ruis toe aan EXPLAIN ANALYZE-uitvoer. Praktischer nog: het verwarrt elke ontwikkelaar die SELECT * FROM notifications uitvoert en probeert te begrijpen welke kolommen voor welk type van belang zijn.

De tweede is de ineenstorting van modelgrenzen. STI plaatst de logica van elke variant in subklassen die een tabel delen, wat betekent dat validaties, callbacks en scopes voortdurend moeten verdedigen tegen type-overschrijdende vervuiling. Een before_save die zinvol is voor EmailNotification wordt geërfd door SmsNotification, tenzij je het bewaakt. Een databasebeperking die alleen voor WebhookNotification zou moeten gelden, kan niet op databaseniveau worden uitgedrukt omdat de kolom nullable moet zijn voor elk ander type. Je eindigt met validaties die databasebeperkingen zouden moeten zijn in Ruby, en in kwetsbare if type == 'X'-bewakers die kapotgaan bij refactors.

De vuistregel die ik gebruik: STI is de juiste keuze als je twee of drie typen hebt, de varianten 80% of meer van hun kolommen delen, en de type-specifieke logica minimaal is. Zodra je vier of vijf typen hebt met betekenisvolle type-specifieke data, stuur je op problemen af.

Delegated Types: Het Kernidee

Rails delegated types splitst de representatie overzichtelijk in tweeën:

  1. Een gedeelde tabel bevat elk attribuut dat zinvol is voor alle varianten — timestamps, eigenaarschap, status, alles wat je over typen heen opvraagt.
  2. Een per-type tabel bevat elk attribuut dat specifiek is voor die variant. Geen nulls. Geen kolomcompromis. Alleen de kolommen die daar thuishoren.

De gedeelde tabel heeft twee speciale kolommen: een _type-string en een _id-integer. Onder de motorkap is dit een polymorfe associatie, maar Rails beheert de foreign key-logica en voegt een nette query-API bovenop toe. Het “delegate”-gedeelte betekent dat je verklaart welke methoden van het gedeelde model doorgestuurd moeten worden naar het type-specifieke record.

Een Echt Voorbeeld Opbouwen: Een Meldingssysteem

Stel je bouwt een SaaS-app die meldingen verstuurt via meerdere kanalen. Alle meldingen delen: een gebruiker, een titel, een read_at-timestamp en een created_at. Maar elk kanaal heeft zijn eigen velden:

  • EmailNotification: to_address, from_address, email_message_id, opened_at
  • SmsNotification: phone_number, twilio_message_sid
  • PushNotification: device_token, fcm_message_id, badge_count
  • InAppNotification: action_url, icon, dismissable

Hier is de migratie voor de gedeelde tabel:

class CreateNotifications < ActiveRecord::Migration[8.0]
  def change
    create_table :notifications do |t|
      t.references :user, null: false, foreign_key: true
      t.string  :notifiable_type, null: false
      t.bigint  :notifiable_id,   null: false
      t.string  :title,           null: false
      t.datetime :read_at
      t.timestamps

      t.index [:notifiable_type, :notifiable_id], unique: true
    end
  end
end

En de per-type tabellen:

class CreateEmailNotifications < ActiveRecord::Migration[8.0]
  def change
    create_table :email_notifications do |t|
      t.string   :to_address,       null: false
      t.string   :from_address,     null: false
      t.string   :email_message_id
      t.datetime :opened_at
      t.timestamps
    end
  end
end

class CreateSmsNotifications < ActiveRecord::Migration[8.0]
  def change
    create_table :sms_notifications do |t|
      t.string :phone_number,       null: false
      t.string :twilio_message_sid
      t.timestamps
    end
  end
end

class CreatePushNotifications < ActiveRecord::Migration[8.0]
  def change
    create_table :push_notifications do |t|
      t.string  :device_token, null: false
      t.string  :fcm_message_id
      t.integer :badge_count, default: 0, null: false
      t.timestamps
    end
  end
end

class CreateInAppNotifications < ActiveRecord::Migration[8.0]
  def change
    create_table :in_app_notifications do |t|
      t.string  :action_url
      t.string  :icon
      t.boolean :dismissable, null: false, default: true
      t.timestamps
    end
  end
end

Elke per-type tabel heeft alleen de kolommen die er zinvol zijn. phone_number is NOT NULL in sms_notifications omdat het altijd aanwezig moet zijn — die beperking kan niet worden uitgedrukt via de STI-aanpak zonder complexe validaties.

De Modelopzet

Het gedeelde model declareert delegated_type:

# app/models/notification.rb
class Notification < ApplicationRecord
  belongs_to :user

  delegated_type :notifiable,
                 types: %w[EmailNotification SmsNotification PushNotification InAppNotification],
                 dependent: :destroy

  scope :unread, -> { where(read_at: nil) }
  scope :read,   -> { where.not(read_at: nil) }

  def mark_read!
    update!(read_at: Time.current)
  end
end

Elk type-specifiek model bevat een concern dat Rails niet vereist maar dat ik altijd toevoeg:

# app/models/concerns/notifiable.rb
module Notifiable
  extend ActiveSupport::Concern

  included do
    has_one :notification, as: :notifiable, touch: true
  end
end

# app/models/email_notification.rb
class EmailNotification < ApplicationRecord
  include Notifiable

  validates :to_address, :from_address, presence: true
  validates :to_address, format: { with: URI::MailTo::EMAIL_REGEXP }
end

# app/models/sms_notification.rb
class SmsNotification < ApplicationRecord
  include Notifiable

  validates :phone_number, presence: true,
            format: { with: /\A\+[1-9]\d{7,14}\z/ }
end

# app/models/push_notification.rb
class PushNotification < ApplicationRecord
  include Notifiable

  validates :device_token, presence: true
end

# app/models/in_app_notification.rb
class InAppNotification < ApplicationRecord
  include Notifiable
end

De validaties zijn overzichtelijk en geïsoleerd. SmsNotification valideert het telefoonnummerformaat; EmailNotification valideert het e-mailformaat. Geen van beide weet van het bestaan van de ander, en geen van beide besmet het gedeelde model met type-specifieke bewakers.

Delegated Types Opvragen

Rails genereert automatisch scope-methoden op basis van de types:-lijst:

# Alle meldingen voor een gebruiker
user.notifications.unread

# Alleen e-mailmeldingen — gegenereerde scope
Notification.email_notifications

# Alleen pushmeldingen
Notification.push_notifications

# Type-predicaten
notif = Notification.first
notif.email_notification?   # => true of false
notif.notifiable_class      # => EmailNotification
notif.email_notification    # => #<EmailNotification ...> of nil

De scope-namen zijn de underscore-versies van de modelnamen. EmailNotification wordt email_notifications, PushNotification wordt push_notifications. Eenvoudig genoeg.

Het onderdeel dat bijna iedereen verrast, is eager loading. Omdat het type-specifieke record een polymorfe associatie is, veroorzaakt een naïeve loop over meldingen één query per rij:

# N+1 — doe dit niet
Notification.unread.each do |n|
  puts n.notifiable.to_address if n.email_notification?
end

De oplossing is includes:

# Één query voor meldingen, één per type in de resultatenset
Notification.unread.includes(:notifiable)

Rails groepeert de polymorfe ID’s per type en stelt één query per type uit de resultatenset op. Als je 100 ongelezen meldingen hebt en 60 zijn e-mail, 30 zijn in-app en 10 zijn SMS, krijg je vier queries in totaal: één voor notifications, één voor email_notifications, één voor in_app_notifications, één voor sms_notifications. Dat is het gewenste gedrag.

Voeg voor de geïndexeerde dashboardquery met paginering de typekolom toe aan een samengestelde index:

add_index :notifications, [:user_id, :read_at, :notifiable_type]

Ik ga dieper in op indexstrategie in Rails database-indexeringsstrategieën — het samengestelde indexprincipe is daar direct van toepassing.

Methoden Delegeren naar het Type-Specifieke Record

Een van de nuttigste patronen is het delegeren van een gemeenschappelijke interface naar het type-record, zodat aanroepers nooit hoeven te weten met welk type ze te maken hebben:

# app/models/notification.rb
class Notification < ApplicationRecord
  belongs_to :user

  delegated_type :notifiable,
                 types: %w[EmailNotification SmsNotification PushNotification InAppNotification],
                 dependent: :destroy

  delegate :channel_identifier, to: :notifiable
  delegate :delivery_status,    to: :notifiable, allow_nil: true
end

Elk type implementeert de interface:

class EmailNotification < ApplicationRecord
  include Notifiable

  def channel_identifier = to_address
  def delivery_status    = email_message_id ? :delivered : :pending
end

class SmsNotification < ApplicationRecord
  include Notifiable

  def channel_identifier = phone_number
  def delivery_status    = twilio_message_sid ? :delivered : :pending
end

class PushNotification < ApplicationRecord
  include Notifiable

  def channel_identifier = device_token
  def delivery_status    = fcm_message_id ? :delivered : :pending
end

class InAppNotification < ApplicationRecord
  include Notifiable

  def channel_identifier = action_url || "(in-app)"
  def delivery_status    = :delivered
end

Nu hoeft een renderer voor een meldingentabel helemaal niet te vertakken op type:

# In een view of presenter
notifications.each do |n|
  render_row(title: n.title, channel: n.channel_identifier, status: n.delivery_status)
end

Dit is het patroon waar ik het vaakst naar grijp. Het gedeelde model definieert het contract, de type-records implementeren het, en de aanroeper weet niets van de variant.

Records Aanmaken

Het aanmaakpatroon is iets uitgebreider dan STI — dat is de afweging die je accepteert:

# Bouw beide objecten en laat het gedeelde model de koppeling beheren
notification = Notification.create!(
  user: current_user,
  title: "Je bon voor bestelling #1234",
  notifiable: EmailNotification.new(
    to_address: current_user.email,
    from_address: "billing@example.com"
  )
)

# Of maak eerst het type-specifieke record aan
email_notif = EmailNotification.create!(
  to_address: params[:email],
  from_address: "noreply@example.com"
)
notification = Notification.create!(
  user: current_user,
  title: "Bevestig je e-mailadres",
  notifiable: email_notif
)

Ik geef de voorkeur aan de eerste vorm — het gedeelde record en het type-record worden aangemaakt in één transactie, zodat je nooit kunt eindigen met een losstaand email_notifications-rij zonder bijbehorend notifications-ouderrecord.

Migreren Vanuit STI: Het Stappenplan

Als je een bestaand STI-model hebt en wilt overstappen op delegated types, heeft de migratie drie fasen. De zero-downtime-beperkingen betekenen dat je dit niet in één grote stap kunt doen — ik behandel de algemene aanpak in Rails zero-downtime migraties en de veiligheidstooling in Rails Strong Migrations.

Fase 1: Voeg de nieuwe tabellen en de polymorfe kolommen toe zonder de bestaande STI-tabel aan te raken.

class AddDelegatedTypesToNotifications < ActiveRecord::Migration[8.0]
  def change
    add_column :notifications, :notifiable_type, :string
    add_column :notifications, :notifiable_id,   :bigint
    add_index  :notifications, [:notifiable_type, :notifiable_id], unique: true

    create_table :email_notifications do |t|
      t.string   :to_address,   null: false
      t.string   :from_address, null: false
      t.timestamps
    end

    # … andere per-type tabellen
  end
end

Fase 2: Vul de gegevens in. Voer een Rake-taak of een eenmalige job uit die de bestaande STI-rijen doorloopt, het type-specifieke record aanmaakt en de polymorfe kolommen invult:

# lib/tasks/migrate_notifications.rake
namespace :db do
  task migrate_notifications_to_delegated_types: :environment do
    Notification.where(notifiable_type: nil).find_each do |n|
      type_record = case n.type
      when "EmailNotification"
        EmailNotification.create!(
          to_address:   n.read_attribute(:to_address),
          from_address: n.read_attribute(:from_address)
        )
      when "SmsNotification"
        SmsNotification.create!(phone_number: n.read_attribute(:phone_number))
      # …
      end

      n.update_columns(
        notifiable_type: type_record.class.name,
        notifiable_id:   type_record.id
      )
    end
  end
end

Gebruik find_each voor batching en update_columns om callbacks en validaties over te slaan — je verplaatst data, je voert geen bedrijfslogica uit. Zie de notities over Rails callbacks voor waarom je ze hier overslaat.

Fase 3: Flip het model en verwijder de oude kolommen in een volgende deploy zodra de vulling compleet is en de nieuwe code in productie is gevalideerd.

class DropLegacyNotificationColumns < ActiveRecord::Migration[8.0]
  def change
    remove_column :notifications, :type
    remove_column :notifications, :to_address
    remove_column :notifications, :from_address
    remove_column :notifications, :phone_number
    # … alle STI-era kolommen
  end
end

Verwijder kolommen nooit in dezelfde deploy als de codewijziging. Strong Migrations dwingt dit af als het geconfigureerd is — en dat zou het moeten zijn.

Wanneer STI de Juiste Keuze Blijft

Delegated types zijn niet altijd het antwoord. Ik houd STI wanneer:

  • Er twee of drie typen zijn die vrijwel al hun kolommen delen. ShippingAddress en BillingAddress die alleen verschillen in een type-kolom en een default-boolean passen perfect bij STI. Twee aparte tabellen daarvoor aanmaken is ceremonie zonder voordeel.
  • Het model klein en gesloten is. Als je er zeker van bent dat de typelijst niet verder groeit dan drie items, wint de eenvoud van STI.
  • Het querypatroon bijna altijd type-overschrijdend is. Account.all en Account.where(...) die niet vertakken op type zijn in STI prima efficiënt. Delegated types voegt altijd een JOIN toe als je type-specifieke data nodig hebt.

De vraag die ik stel: “Zal elk type-specifiek attribuut in de gedeelde tabel moeten staan, en zal het NULL zijn voor alle typen behalve één?” Als het antwoord ja is voor meer dan een paar kolommen, is delegated types de juiste stap.

De Prestatierealiteit: Het Is een JOIN

Het lezen van een delegated type-record vereist altijd een JOIN of een tweede query. Dat is de prijs die je betaalt voor het schone schema. In de praktijk is het geen probleem — de queries zijn eenvoudig, de indexes zijn voor de hand liggend, en een goed opgezette samengestelde index op de gedeelde tabel over [user_id, read_at] betekent dat de queryplanner de per-type tabellen nooit aanraakt totdat hij de resultatenset al tot een klein aantal rijen heeft gereduceerd.

De plek waar prestaties je verrassen is wanneer je vergeet includes(:notifiable) toe te voegen en N+1-queries krijgt op een lijstpagina. Voeg een Bullet-configuratie toe of gebruik strict_loading op de associatie als je team het nog niet heeft aangenomen:

class Notification < ApplicationRecord
  delegated_type :notifiable, types: %w[...], dependent: :destroy
end

# In config/environments/development.rb
config.active_record.strict_loading_by_default = true

Met strict loading ingeschakeld in development gooit Rails een fout de eerste keer dat iemand een delegated type-associatie lui laadt in een loop. De oplossing is altijd dezelfde: voeg includes toe.

Veelgestelde Vragen

Wat is het verschil tussen Rails delegated types en polymorfe associaties?

Een polymorfe associatie (belongs_to :commentable, polymorphic: true) definieert een relatie tussen modellen. Delegated types definiëren een hiërarchie: één “basis”-concept (melding, bericht, betaling) met meerdere concrete implementaties. Je zou een polymorfe associatie gebruiken als Comment bij zowel een Post als een Video kan horen — twee niet-gerelateerde modellen. Je zou delegated types gebruiken als Notification, EmailNotification en SmsNotification hetzelfde concept vertegenwoordigen op verschillende niveaus van specificiteit.

Werken Rails delegated types samen met STI tegelijk?

Je kunt ze combineren, maar dat zou je waarschijnlijk niet moeten doen. Als je merkt dat je naar beide grijpt in dezelfde hiërarchie, is dat meestal een teken dat het model te veel doet. Splits het concept eerst, kies dan één overervingsstrategie per hiërarchie.

Hoe verwerk ik formulieren met Rails delegated types?

Gebruik Rails’ fields_for met het notifiable-subrecord, of gebruik een formulierobject dat zowel de gedeelde als type-specifieke attributen omhult. De aanpak met accepts_nested_attributes_for :notifiable werkt als je het type kent voordat het formulier wordt weergegeven; als het type door de gebruiker wordt geselecteerd, is een service-object dat beide records opbouwt overzichtelijker.

# app/forms/create_notification_form.rb
class CreateNotificationForm
  include ActiveModel::Model

  attr_accessor :user, :title, :type, :to_address, :phone_number

  def save
    type_record = case type
    when "email" then EmailNotification.new(to_address: to_address, from_address: "noreply@example.com")
    when "sms"   then SmsNotification.new(phone_number: phone_number)
    end

    Notification.create!(user: user, title: title, notifiable: type_record)
  end
end

Cascade het DESTROY op het gedeelde record naar het per-type record?

Ja, als je dependent: :destroy doorgeeft aan delegated_type. Dit is de standaard die ik gebruik — een losstaand email_notifications-rij zonder bijbehorend notifications-item is erger dan per ongeluk te veel verwijderen. Als je een bewaarplicht hebt voor de type-specifieke data, gebruik dan dependent: :nullify en verwerk de opruiming expliciet.


Heb je te maken met een Rails-codebase waarbij STI de dragende muur is geworden die niemand wil aanraken? TTB Software ontrafelt dit soort architecturen al negentien jaar. We weten welke migraties je in welke volgorde moet uitvoeren, en welke shortcuts je aan de andere kant duur komen te staan.

#rails-delegated-types #rails-sti-alternative #single-table-inheritance-rails #rails-polymorphic-associations #rails-activerecord-inheritance #rails-architecture-patterns

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software