The "Super-Model" Pattern in Rails
K
Kaka Ruto
The "Super-Model" Pattern in Rails

How you can adopt the 37signals 'Recordables' pattern to centralize your logic, secure your data, and make your Rails monolith scale effortlessly

The "Super-Model" Pattern in Rails


If you follow the engineering blog over at 37signals, you might have caught a fascinating deep dive by their Principal Programmer, Jeffrey Hardy. He explained the architecture that has powered Basecamp for over a decade.


They call it the Recording / Recordable pattern, link here.


It is the secret sauce that allows Basecamp to handle Messages, To-Dos, Uploads, and Documents all in a single, unified timeline with unified access control. It was also the perfect blueprint I used to refactor Kawibot after many a problem with accessing resources with code duplicated in many different places.


At Kawibot, we are building a file system that our users can use to store their files - just like Google Drive. We have Libraries (Folders) and LibraryFiles (Files). Currently, we are treating these as two totally separate models, glued together with complex polymorphic associations. 


Here is how I refactored Kawibot to use the "37signals Way.", and you can follow the same process too.


The Core Concept: The Wrapper and The Meat


In the 37signals architecture, they split every piece of content into two parts. For Kawibot, we will call these the Entry and the Entryable.


1. The Entry (The Wrapper)

Think of this as the "System of Record." As Jeffrey Hardy explains, this table holds the metadata that everything shares. In Kawibot, this is where we store:


 • Who owns it (account_id)


 • Where it lives (parent_id - crucial for our folder tree!)


 • Who can access it (via our new EntryAccess system)


2. The Entryable (The Meat)

This is the specific content.


 • The Library table holds folder-specific data (color, icon).


 • The LibraryFile table holds file-specific data (s3_key, file_size, mime_type).


The Entry simply points to the Entryable.


Refactoring the Model


This relies on the native Rails delegated_type feature. Here is how we restructure our models to match the pattern:

# app/models/entry.rb
class Entry < ApplicationRecord
  belongs_to :account
  # The 37signals "Tree" Magic
  # Instead of Libraries having children, the ENTRY has children.
  belongs_to :parent, class_name: "Entry", optional: true
  has_many :children, class_name: "Entry", foreign_key: "parent_id", dependent: :destroy
  delegated_type :entryable, types: %w[ Library LibraryFile Article ]
  # Granular Permissions Association
  has_many :entry_accesses, dependent: :destroy
  has_many :authorized_users, through: :entry_accesses, source: :account_user
end
# app/models/library_file.rb
class LibraryFile < ApplicationRecord
  has_one :entry, as: :entryable, touch: true
end


One Tree to Rule Them All


The biggest pain point in the old way of building Kawibot is nesting. You have to write logic so a Library can contain other Libraries, but it can also contain Files.


By refactoring to this pattern, we solve this instantly. The Library model no longer needs to know about children.


When we want to show the contents of a folder, we don't query the Library. We query the Entry.

# One query to get files AND sub-folders, sorted by name
@folder_contents = @current_folder.children.includes(:entryable).order(:name)


This is exactly how Basecamp renders a project timeline containing mixed content (Messages and Events) in a single, fast database query.


Granular Permissions with EntryAccess


Now, let’s talk about security. In a file system like Kawibot, simple "Account Ownership" isn't enough. You need to be able to share a specific folder with a specific user, or give someone "View Only" access to a file.


In the old world, you would need a LibraryAccess table and a LibraryFileAccess table. This is a maintenance nightmare.


Because we have wrapped everything in an Entry, we only need one permissions table: EntryAccess.


The Schema Strategy

Your entry_accesses table is the single source of truth for who can do what.


 • It links an entry_id to an account_user_id.


 • It defines the access_type ("view", "edit", "admin").


 • It enforces uniqueness: A user can only have one access level per entry.


Powering Pundit with EntryAccess

This structure dramatically simplifies our Pundit policies. Instead of writing complex logic for Files vs. Folders, we write one EntryPolicy that queries the EntryAccess table.


The policy delegates the hard work to the data model. If the record is a File, we check the File's Entry. If the record is a Folder, we check the Folder's Entry. The policy doesn't care—it just checks the access table.

# app/policies/entry_policy.rb
class EntryPolicy < ApplicationPolicy
  def show?
    # Anyone with a row in the EntryAccess table can view it
    # (Assuming we pre-load this relationship for performance)
    record.entry_accesses.exists?(account_user: user)
  end
  def update?
    # Only 'edit' or 'admin' roles can update
    access = record.entry_accesses.find_by(account_user: user)
    access && %w[edit admin].include?(access.access_type)
end
  def destroy?
    # Only 'admin' role can delete
    access && access.access_type == 'admin'
end
end


What Should (and Should Not) Use This Pattern?


It is tempting to throw every model in your app into this pattern, but that is a mistake. The Delegated Type pattern is designed for User Content, not System Infrastructure.


Here is the litmus test to decide if a model should be an Entryable.


Use the Pattern for Content:

If the item lives in a folder, appears in lists alongside other items, or needs user-facing permissions, it is an Entryable.


 • Libraries & Files: The core use case.


 • Articles: Article is a perfect candidate. It behaves exactly like a File but holds text.


 • Shortcuts: If you add "Links to other folders," this is an Entryable.


 • Tasks: If users can create To-Do lists inside folders.


Do NOT Use the Pattern for Infrastructure:

If the item acts upon content or contains the content, it is likely infrastructure.


 • Accounts: The Account is the container for the entire system. It does not "live" inside a folder.


 • AccountUsers: Users have permissions to Entries; they are not Entries themselves.


 • Tags: Tags are metadata attached to an Entry. They don't need their own permissions or parent folders.


 • ActivityLogs: These are system records. You don't "move" or "rename" a log entry.


Refactoring the Controllers


When it comes to Controllers, we should follow the 37signals hybrid approach: Centralize the Management, Separate the Creation.


Jeffrey Hardy notes that while they have a generic RecordingsController for moving and trashing items, they still often keep specific controllers for creating them because the forms are different.


For Kawibot, this means:


1. The EntriesController (For Management)

We use this controller for the actions that are the same for everyone.


 • Destroy: Deleting a file or a folder uses the exact same code (@entry.destroy).


 • Move: Dragging a file into a new folder is just updating the @entry.parent_id.


2. The LibraryFilesController (For Creation)

We keep a specific controller for the "Upload" action, because handling a file upload is distinct from creating a folder. However, inside that controller, we wrap the result in an Entry:

def create
  @file = LibraryFile.new(file_params)
  # Wrap it in an Entry automatically
  @entry = Entry.create!(entryable: @file, parent_id: params[:parent_id], ...)
  # Automatically grant Admin access to the creator
  EntryAccess.create!(entry: @entry, account_user: current_user, access_type: 'admin')
end


The Wins


The Polymorphic Way:


Nesting was painful. You had to stitch together sub_libraries and files manually.


Permissions were scattered. You needed multiple Access tables. If you added a "Google Doc" type later, you'd need a GoogleDocAccess table too.


Logic was duplicated. You had to write "Can Edit?" logic in multiple places.


The Super-Model Way:


Nesting is solved. Files and Folders live in the same entries table, so the hierarchy is unified.


Permissions are Global. The EntryAccess table handles security for everything. Adding a new content type (like a "Shortcut" or "Note") requires zero new permission code. You just make it an Entryable.


Performance is native. We can paginate a folder with thousands of files and sub-folders in a single SQL query.


This pattern allows Basecamp to stay a "Majestic Monolith" even after 10 years of development. For Kawibot, it transforms a complex file-system problem into a simple tree of Entries with a robust, unified security model.

Keep Reading

View all articles
Previous Article
Next Article