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
Boom.
One table, one type
column, one lazy JSON field for everything else.
Problem solved.
Until you start thinking about TVShow
↔ Season
↔ Episode
relationships, or how content availability depends on both app and market.
Suddenly your “clean” table turns into a minefield of NULL
s, 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
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
And its migration:
create_table :catalog_entries, id: :uuid do |t|
t.references :content, polymorphic: true, type: :uuid, null: false
t.timestamps
end
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...
Want to filter by type?
CatalogEntry.where(content_type: 'Movie')
Clean, flexible, scalable — the way polymorphism was meant to be used.
🤓 Key Takeaways
STI looks elegant until it bites back.
If your models share some fields but have different relationships, go polymorphic.Concerns are criminally underrated.
They let you share real behavior, not just columns.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)