❯ You mentioned - "Option 2 specifically because it gives us real FK constraints unlike polymorphic"
Can you elaborate on the advantages.
⏺ What polymorphic looks like at the DB level
thread_follow_ups
├── threadable_type (string: "StockThread" or "AnalysisThread")
├── threadable_id (bigint)
The database has no idea that threadable_id points to stock_threads or analysis_threads. It's just a string + integer. The mapping only exists in Rails application code.
What two nullable FKs look like
thread_follow_ups
├── stock_thread_id (bigint FK → stock_threads.id)
├── analysis_thread_id (bigint FK → analysis_threads.id)
The database enforces that these point to real rows.
What FK constraints actually protect you from
1. Orphaned records
If someone deletes a StockThread (or a bug does it, or a migration goes wrong):
- Polymorphic: Follow-ups still exist with threadable_type: "StockThread", threadable_id: 47. Point to nothing. App crashes when it tries to load the thread. You discover this at runtime.
- FK constraint: Database refuses the delete (or cascades the delete if configured). Impossible to create orphans.
2. Bad data from bugs or manual DB edits
A bug writes threadable_id: 99999 (doesn't exist), or threadable_type: "StockThraed" (typo):
- Polymorphic: Silently saved. Discovered later when someone queries it.
- FK constraint: stock_thread_id: 99999 fails immediately — the row doesn't exist in stock_threads.
3. Rails class renames
If you ever rename StockThread to something else:
- Polymorphic: Every row with threadable_type: "StockThread" is now broken. Requires a data migration to update the string column.
- FK constraint: Column name is stock_thread_id — the integer FK doesn't care what the Ruby class is called.
When does this matter practically?
For a solo/small-team project, honestly, polymorphic works fine 90% of the time. Rails handles it well. But per the CLAUDE.md principle:
Would this design hold up if the product scaled 100x and a team of engineers inherited the codebase?
At scale — multiple developers, background jobs writing concurrently, data migrations, possible future admin tools that bypass Rails — FK constraints are the kind of thing you're grateful for. They're the database saying "I won't let you corrupt yourself" regardless of what application code does.
The tradeoff
The cost of Option 2 is minor Rails awkwardness — two belongs_to instead of one, a helper method, slightly more verbose queries. The cost of polymorphic is that data integrity lives in application code instead of the database.
For a table that's going to be one of the highest-write, most-queried tables in the system (every NewsItem generates follow-ups, every cross-cutting query hits
it), I'd take the DB-level guarantee.
✻ Worked for 57s
❯ In a way, Polymorphic Associations are an anti-pattern, then? It seems to me that we can only use them for things like Comments where the 'parent' entities do not combine with each other. Here we have StockThread and AnalysisThread that have some sort of a relationship and have potential queries that combine them together (you mentioned Union of results). Is that a good rule of thumb to go by, to determine when Polymorpohic Associations are good or not ? I am again being vague here.
Please pushback or support as necessary.
⏺ Your intuition is solid, but let me refine it a bit.
Polymorphic isn't an anti-pattern universally — it's a pattern with a specific cost
The cost is always the same: no FK constraints, no JOINs through the polymorphic column. The question is whether that cost matters for the specific use case.
Your rule of thumb, refined
You said: "use polymorphic only when parent entities don't combine with each other." That's close, but I'd frame it from the child's perspective, not the parent's:
Polymorphic is fine when the child records are always queried in the context of one parent at a time.
Comments are the textbook example:
- "Show me comments on this NewsItem" — always scoped to one parent
- You never need "all comments across all content types sorted by date"
- You never need to JOIN comments with their parent for cross-cutting analytics
- If a NewsItem is deleted and orphans a comment, the blast radius is small
Polymorphic is problematic when the child records are the core data asset that you query across parents.
ThreadFollowUp is exactly this case:
- "All follow-ups triggered by this Reuters article" — crosses both parent types
- "What's the total follow-up activity for HDFCBANK this week" — might span StockThread + AnalysisThreads mentioning HDFCBANK
- This table is your event impact ledger — it's the most queried, most joined table in the V2 system
- Orphaned follow-ups would mean lost analytical history
A simple test
Ask: "Will I ever write a query against this table that doesn't have WHERE threadable_type = '...' in it?"
- Comments: No. Every comment query starts from a specific parent. → Polymorphic is fine.
- ThreadFollowUp: Yes. Cross-thread queries, GROUP BY on related_news_item_id, aggregations for weekly summaries. → Polymorphic hurts.
One more nuance
Even for Comments, polymorphic has a cost — you accept the FK integrity tradeoff because the practical risk is low (comments are low-stakes, replaceable). For
ThreadFollowUp, the records represent irreplaceable analytical history — the LLM can't reconstruct "what happened on March 15" after the fact. The integrity guarantee matters more.
So the full decision framework:
┌────────────────┬──────────────────────────────┬─────────────────────────────────────┬──────────────┐
│ │ Query pattern │ Data criticality│ Polymorphic? │
├────────────────┼──────────────────────────────┼─────────────────────────────────────┼──────────────┤
│ Comments │ Always scoped to one parent │ Low stakes (replaceable) │ Fine │
├────────────────┼──────────────────────────────┼─────────────────────────────────────┼──────────────┤
│ Bookmarks │ Always scoped to one parent │ Low stakes │ Fine │
├────────────────┼──────────────────────────────┼─────────────────────────────────────┼──────────────┤
│ ThreadFollowUp │ Cross-cutting queries needed │ High stakes (irreplaceable history) │ Avoid │
├────────────────┼──────────────────────────────┼─────────────────────────────────────┼──────────────┤
│ Audit logs │ Cross-cutting queries needed │ High stakes │ Avoid │
└────────────────┴──────────────────────────────┴─────────────────────────────────────┴──────────────┘