โ† Back to Course

ISP Series ยท 2 of 2

Role-Based Interfaces

Where role interfaces come from, why ActiveModel::Model is itself a composition of small roles, and how to design protocols that callers can satisfy without inheriting a whole class.

Where this rule comes from

The first ISP lesson covered horizontal splits: a fat concern containing four capabilities gets broken into four small concerns, and each class includes only what it uses. This lesson is about a related idea, often called role-based interfaces: instead of asking "does this object descend from class X?" callers ask "does this object play role Y?"

The term comes from Object-Oriented Role Analysis and Modeling, a method from the 1990s by Trygve Reenskaug (the same person who invented MVC). The core observation: a class often plays several roles at different times. A User might be an Author when posting, a Reviewer when commenting, a Subscriber when reading. Asking "is this a User?" forecloses the question that matters: "can this thing act as an Author here?"

In static languages, role-based design uses tiny interfaces. Java's Comparable, Iterable, Serializable are roles, not classes. A class can implement any subset of them. The role defines exactly what the caller needs and nothing more.

Ruby's analogous shape is the small module. Comparable, Enumerable, Kernel, and dozens of ActiveModel modules are all roles. A class declares which roles it plays by including the matching modules. Callers depend on the role, not on the class.

The ISP-shaped move in Rails is: when designing a class that other code will collaborate with, think in roles. Each role is a small, named protocol. Callers depend on the role they need, not on the class as a whole.

The anti-pattern

Picture a Rails app where the team built a "user activity log" feature. The first version assumed activity could only come from User records, because that was the only case at launch:

class ActivityLog
  def self.record(actor, action, subject)
    raise ArgumentError unless actor.is_a?(User)

    create!(
      user_id:      actor.id,
      user_email:   actor.email,
      user_name:    actor.name,
      action:       action,
      subject_type: subject.class.name,
      subject_id:   subject.id
    )
  end
end

This class is coupled to the User class. Three things follow:

  • The day the team adds service accounts (a separate ServiceAccount model that can also perform actions), the activity log refuses to record anything they do.
  • The day the team adds API tokens that act on behalf of users but should be attributable to the token, not the user, there is nowhere to put that information.
  • Tests have to instantiate a real User to record an activity. The actor argument is class-typed, so any test substitute has to be a User, even if the test only cares about how activities are recorded.

The class is doing two things: checking the actor's type, and reading the fields it needs from the actor. The type check is doing none of the useful work; the field reads are the actual job.

The role-based refactor

Replace the class check with a role check. The activity log does not need a User; it needs something that can answer id, email, and name. That is the role of an "Actor." Make the role explicit:

# Roles are documented as a module. The module does not have
# to be included anywhere; it merely names the protocol.
module Actor
  # Required methods:
  #   id    -> Integer | String, unique within actor type
  #   email -> String, where to send notifications
  #   name  -> String, human-readable label
end

class ActivityLog
  def self.record(actor, action, subject)
    create!(
      actor_id:     actor.id,
      actor_email:  actor.email,
      actor_name:   actor.name,
      action:       action,
      subject_type: subject.class.name,
      subject_id:   subject.id
    )
  end
end

The change is small, and the consequences are large. Anything that quacks like an Actor, a User, a ServiceAccount, an ApiToken, a test double, can now be passed in. The class does not check what the actor is; it asks the actor what callers need to know. The role is the contract.

Each actor type still has its own class, with its own internals. User has billing, sessions, preferences. ServiceAccount has API rate limits, auth tokens. They are unrelated except that both can play the Actor role by exposing the three methods the role requires.

How Rails composes roles itself

The clearest example of role composition in Rails is ActiveModel::Model. When you read its source, it is not a single fat module, it is a top-level convenience that includes a small set of focused roles:

# rails/activemodel/lib/active_model/model.rb ยท @main
# License: MIT
#
# Simplified to show the role composition. The real source has
# a few more includes for backwards compatibility.

module ActiveModel
  module API
    extend ActiveSupport::Concern

    include AttributeAssignment
    include Validations
    include Conversion
  end

  module Model
    extend ActiveSupport::Concern

    include API
    include Access
    include Naming
    include Translation
    include AttributeMethods
    include Serialization
    # ... a handful more
  end
end

Each included module is a role:

  • AttributeAssignment, "I can have attributes set from a hash."
  • Validations, "I can be validated and have errors."
  • Naming, "I have a model_name that views can use."
  • Conversion, "I can be converted to keys, params, and partial paths."
  • Serialization, "I can be serialized to a hash."

A class that wants the full "behaves like a model" experience includes ActiveModel::Model. A class that needs only validations and naming can include those two roles directly. A class that needs only validations (a form object that does not need to render in a view) can include only ActiveModel::Validations.

This is ISP enacted as a library design. The framework exposes small, focused roles. Application code picks which ones it needs. The fat convenience module (ActiveModel::Model) is there for the common case, but every role underneath it is independently usable.

How to design a role in practice

Three steps:

1. Name the role from the caller's perspective. The activity log's caller sees the argument as "an actor," not as "a user." Name the role for what the caller needs, not for the concrete class that happens to satisfy it today. An Actor role is portable; a User role is not.

2. List the methods the role requires. Keep it small. Three or four methods is typical. If the role grows past five or six, ask whether it is actually two roles glued together. A role that does many things is hard to satisfy; a role that does few things is hard to misuse.

3. Document the role somewhere. The Ruby convention is a module with no implementation, only a docstring listing the required methods. Some teams use a comment block instead. Either way, the role is named in the codebase, not only in someone.s head. Tests for the role can check that every claimed implementer answers the protocol.

Optional fourth step: provide a shared test helper that exercises the role. RSpec calls this a "shared example." Minitest calls it a module of test methods. Any class claiming to play the role runs the shared tests against itself, which catches drift the day someone adds a new implementer that forgets a method.

Why this design holds up

Four wins come out of the role-based design.

The caller depends on what it needs. Not on the User class with its 40 methods, but on the Actor role with its 3. The change radius shrinks: changes to User's billing or session logic cannot break the activity log, because the log never touched those parts.

New implementers can be added without modifying the caller. ServiceAccount, ApiToken, even a test double, anything that exposes the right three methods works. The caller is closed to modification, open to new actor types.

Test substitutes get trivial. A test that wants to record activity does not need a full User factory. It can use Struct.new(:id, :email, :name).new(1, "a@b", "Alice"), three lines, no database. The role is the test seam.

The system documents itself. A reader looking at the activity log sees that it depends on an Actor protocol, not on User. The boundary is visible in the code, not hidden in implicit class checks.

When NOT to invent a role

Role-based design pays for itself when more than one class implements the role, or when one is genuinely coming. Inventing a role with one implementer is overhead. The User class can be the actor for a long time without it costing anything. Only when a second actor type is on the roadmap (or already a workaround in the codebase) is the role worth naming.

A second guard: do not invent role abstractions for tests alone. If the only reason to extract a role is to make a test easier, the right fix is usually a small builder helper, not a redesign. Roles earn their keep when they let the production code accept more than one real implementation.

Smell to watch for: a method that takes an argument and immediately type-checks it. raise unless x.is_a?(User), return if x.is_a?(Admin). Every type check is a place where a role could replace a class. The check usually exists because the method's contract was never written down; the role is what makes the contract explicit.

The principle at play

The Interface Segregation Principle, restated for Ruby: callers should depend on roles, not on classes. A role is a small protocol the caller actually uses. A class is everything that happens to be true about an object. The two are not the same, and conflating them produces unnecessary coupling.

The deeper move is to shift the design conversation from "what is this thing?" to "what does this thing need to do for me?" The first question forecloses substitution; the second invites it. Every role is a place where future implementations can plug in.

Rails' framework code follows this rule almost to the letter. ActiveModel is dozens of small roles composed into convenience bundles. ActiveJob defines a tiny adapter role; any backend that implements it can substitute. ActiveStorage defines a service role; any backend that implements it can substitute. The framework's flexibility, what makes it possible to swap pieces in and out, is role-based design carried out at scale. Your application code can borrow the same shape.

Practice exercise

  1. Run grep -rEn "is_a?\\(User\\)|is_a?\\(Account\\)|kind_of?\\(" app/ in your app. Each match is a place where a role would replace a class.
  2. For each match, identify the methods the surrounding code actually calls on that argument. Three or four methods is typical. That short list is the role the caller actually needs.
  3. Name the role. (Hint: it usually ends in "-able" or "-or", Actor, Postable, Notifiable.) Write a module that documents the required methods.
  4. Replace the type check with role-based code. Now run your tests. If they all pass, the role was always implicit; you only made it explicit.
  5. Bonus: open activemodel/lib/active_model.rb in your bundle directory and read the file. Every autoload line is a role Rails ships. Pick one you have never used and read its source. It will be small and self-contained.