DEV Community

Cover image for Polymorphic Relationships Matter (and My ADHD Agrees)
develaper
develaper

Posted on

Polymorphic Relationships Matter (and My ADHD Agrees)

TL;DR: Building an API for movies, shows, and channels got messy fast. I ditched Single Table Inheritance for polymorphism, built a shared CatalogEntry index, and learned why “country” and “market” are not the same thing.

A few days ago, I got an assignment that looked pretty harmless at first glance:

“Build an API that exposes data for different types of audiovisual content.”

Easy, right?
Spoiler: it wasn’t.

The API needed to handle movies, TV shows (with their seasons and episodes), channels, and programs — each with its own structure, relationships, and availability rules.

Nothing scary if you’ve been doing Rails for a while...
until you realize there are six different content types, all kinda similar but not really.
And when something’s “kinda similar” in Rails, you know what’s coming:
time to pick your poison — inheritance, polymorphism, or a desperate ActiveRecord hack.


🤠 The Cowboy Phase: Single Table Inheritance

I’ll be honest — my first instinct was pure cowboy mode:

create_table :contents do |t|
  t.string :title
  t.string :type
  t.integer :year
  t.integer :duration_in_seconds
  t.jsonb :other_properties
  t.timestamps
end
Enter fullscreen mode Exit fullscreen mode

Boom.
One table, one type column, one lazy JSON field for everything else.
Problem solved.

Until you start thinking about TVShowSeasonEpisode relationships, or how content availability depends on both app and market.

Suddenly your “clean” table turns into a minefield of NULLs, if type == 'Movie', and scopes that make you want to close your laptop and move to the mountains.


🧬 The Enlightenment: Separate Tables, Shared Brain

So I took a step back.
Each content type should have its own table (movies, tv_shows, seasons, etc.),
but still share logic, validations, and relationships.

That’s when Rails’ concerns come to the rescue.

Here’s my Contentable module:

# app/models/concerns/contentable.rb
module Contentable
  extend ActiveSupport::Concern

  included do
    validates :title, presence: true

    has_many :content_availabilities, as: :content, dependent: :destroy
    has_many :apps, through: :content_availabilities
    has_many :markets, through: :content_availabilities
    has_one :catalog_entry, as: :content, dependent: :destroy

    after_create :create_catalog_entry
  end

  def content_type
    self.class.name.underscore
  end

  private

  def create_catalog_entry
    CatalogEntry.create!(content: self, uuid: self.uuid)
  end
end
Enter fullscreen mode Exit fullscreen mode

Thanks to polymorphic associations (as: :content),
any model that includes Contentable becomes a first-class citizen in the catalog.

No inheritance spaghetti, no redundant code, just clean extensibility.


🧠 Enter the Bridge: CatalogEntry as the Universal Index

To make the API truly dynamic (with a single endpoint like /api/v1/content),
I needed a shared registry where all content types could coexist —
something like a universal index.

That’s where CatalogEntry came in:

class CatalogEntry < ApplicationRecord
  belongs_to :content, polymorphic: true

  validates :uuid, presence: true, uniqueness: true
  validates :content_type, :content_id, presence: true
end
Enter fullscreen mode Exit fullscreen mode

And its migration:

create_table :catalog_entries, id: :uuid do |t|
  t.references :content, polymorphic: true, type: :uuid, null: false
  t.timestamps
end
Enter fullscreen mode Exit fullscreen mode

Now, CatalogEntry acts as a bridge to any content type — movie, TV show, program, whatever.

Want to grab a content record dynamically?

catalog_entry = CatalogEntry.find(params[:id])
catalog_entry.content # => Movie, TVShow, ChannelProgram...
Enter fullscreen mode Exit fullscreen mode

Want to filter by type?

CatalogEntry.where(content_type: 'Movie')
Enter fullscreen mode Exit fullscreen mode

Clean, flexible, scalable — the way polymorphism was meant to be used.


🤓 Key Takeaways

  1. STI looks elegant until it bites back.
    If your models share some fields but have different relationships, go polymorphic.

  2. Concerns are criminally underrated.
    They let you share real behavior, not just columns.

  3. A shared index (like CatalogEntry) saves your API from chaos.
    No more “six queries, one JSON” nonsense.


🧩 Full Code & Repository

You can check out the full implementation, including models, migrations, and specs, on GitHub:

👉 stream-content-api

🚀 Next Episode: Availability, Markets, and the Real Meaning of “Country”

Next up, I’ll break down how I modeled content availability across apps and markets —
and why “country” ≠ “market” turned out to be a bigger philosophical question than I expected.

(Spoiler: polymorphism made another cameo.)


💬 Let’s Talk

Have you ever regretted choosing STI halfway through a project?
Or maybe found an elegant hybrid between the two approaches?
Drop your take in the comments — I love reading real-world war stories from the trenches.

Top comments (0)