When Rails Notifications Get Out of Hand: A Case for Single Table Inheritance

Notifications are everywhere in modern web applications. Users expect to be notified when someone comments on their post, shares their content, or invites them to collaborate in a post. But as your Rails application grows, managing different types of notifications can quickly become a maintenance nightmare.

Recently, while adding new notifications to a feedback management system, I encountered a common pattern that many Rails developers face: multiple notification types sharing similar behavior but with subtle differences. The original implementation used an enumerator column called event to differentiate between notification types, leading to scattered conditional logic and repeated code. Here’s how Single Table Inheritance (STI) provided a cleaner, more maintainable solution.

The Problem: Event-Driven Chaos

The initial notification system relied on an event column with values like ‘publish’, ‘comment’, ‘collaborate’, and ‘praise’. Each notification type required different logic for titles, descriptions, and email templates, resulting in a bloated model full of case statements:

# app/models/notification.rb

def title
  case event
  when 'publish'
    I18n.t('notification.feedback.shared.title')
  when 'comment'
    I18n.t('notification.feedback.commented.title')
  # ... more cases
  end
end

This approach violated the Open/Closed Principle and made adding new notification types cumbersome. Every new feature required modifications to the core Notification model, increasing the risk of introducing bugs.

Enter Single Table Inheritance

STI allows multiple classes to share the same database table while maintaining their own specific behaviors. The base Notification class defines the common interface and shared behavior:

#app/models/notification.rb

class Notification < ApplicationRecord
  belongs_to :user
  belongs_to :notifiable, polymorphic: true

  scope :by_recent, -> { order(created_at: :desc) }
  scope :unread, -> { where(read_at: nil) }
  
  def self.for(user, opts = {})
    where(opts.merge!(user_id: user.id)).order(updated_at: :desc)
  end
  
  def mark_as_read
    update(read_at: Time.current)
  end
  
  def read?
    read_at.present?
  end
  
  def unread?
    !read?
  end
  
  def title
    raise NotImplementedError, 'Subclass must implement #title'
  end
  
  def description
    raise NotImplementedError, 'Subclass must implement #description'
  end
 
  def send_email
    NotifierMailer.send(mailer_action, user, notifiable).deliver_later
  end
  
  def self.create_notification(user, notifiable)
    create(user: user, notifiable: notifiable)
  end
  
  private
  
  def mailer_action
    raise NotImplementedError, 'Subclass must implement #mail'
  end
end

In our case, each notification type became its own class inheriting from this base model:

# app/models/notifications/feedback_shared.rb

class Notifications::FeedbackShared < Notification
  def title
    I18n.t('notification.feedback.shared.title')
  end

  def description
    I18n.t('notification.feedback.shared.description',
      author: feedback.author.first_name)
  end
  
  def notifiable_path
    feedback_path(feedback)
  end
  
  def self.notify(user, feedback)
    create_notification(user, feedback).send_email
  end
  
  private
   
  def feedback
    notifiable
  end
  
  def mailer_action
    :feedback_shared
  end
end

Each notification class encapsulates its own logic, making the codebase more modular and easier to test. With this approach, we can add new notification types without modifying existing code. For example, we can create a dedicated class for comment notifications or for collaboration invitations — the possibilities are virtually unlimited!

Using the New Notification System

With STI in place, creating notifications becomes straightforward and type-safe:

# When feedback is shared
Notifications::FeedbackShared.notify(user, feedback)

# When someone comments
Notifications::Commented.notify(user, comment)

# Querying notifications remains simple
user.notifications.unread.by_recent

Each notification type handles its own email delivery, and text rendering. Adding a new notification type is as simple as creating a new class that inherits from Notification.

Why STI Works Perfectly Here

STI proved to be an ideal fit for our notification system because we leverage Rails’ I18n system for all notification content. Instead of storing titles, descriptions, or email content in the database, we generate them dynamically from translation files. This means all notification types share the same database schema — they only differ in their behavior and the I18n keys they reference.

The polymorphic notifiable association allows us to attach notifications to any model (feedback, comments, reviews), while the type column handles the class-specific logic. We’re not dealing with sparse columns or varying data structures that would make STI problematic—just different ways of presenting the same core notification data.

Benefits Beyond Clean Code

STI brought several unexpected advantages. Testing became more straightforward since each notification type could be tested in isolation. Adding new notification types no longer required touching existing code — just create a new class following the established pattern. The polymorphic association with notifiable remained intact, preserving the flexibility to attach notifications to any model.

Performance also improved slightly. Instead of Rails evaluating case statements at runtime, method dispatch happens at the class level, and specific behavior is defined directly in each subclass.

When STI Isn’t the Answer

STI works well when subclasses share most of their structure but differ in behavior. However, it’s not suitable when notification types require significantly different database columns. In such cases, separate tables with a shared interface might be more appropriate.

Also, be mindful of table bloat. If you have many notification types with sparse data, you might end up with numerous null columns, which can impact query performance.

Moving from event-driven conditionals to STI transformed our notification system from a maintenance burden into an extensible, clean architecture. Each notification type now has its own space to grow, and the codebase tells a clearer story about what each notification does. Have you encountered similar challenges in your Rails applications? I’d love to hear how you’ve approached notification system design in the comments.