Ruby Modules and Mixins — Composition Over Inheritance in Practice
by Eric Hanson, Backend Developer at Clean Systems Consulting
Where inheritance breaks
Deep inheritance chains feel clean on day one. By the time a codebase has ApplicationController < AuthenticatedController < AdminController < ReportController, you've lost the ability to read any single class in isolation. Every method call requires mentally walking up four levels to find the definition — and overriding anything requires knowing what invariants the parent classes depend on.
Ruby is single-inheritance: a class has exactly one superclass. That constraint pushes you toward modules for sharing behavior across unrelated classes. The constraint is a feature. It prevents the diamond problem that multiple inheritance creates in other languages, but it doesn't prevent the complexity — it just moves it somewhere you have to think about explicitly.
What include, prepend, and extend actually do
All three mix a module's methods into a class, but they insert the module at different positions in the method lookup chain (the ancestor chain):
module Auditable
def save
log_change
super
end
end
class Order
include Auditable # Auditable inserted after Order in ancestor chain
prepend Auditable # Auditable inserted before Order in ancestor chain
extend Auditable # Auditable methods become class methods
end
You can inspect the result:
Order.ancestors
# include: [Order, Auditable, Object, ...]
# prepend: [Auditable, Order, Object, ...]
include is the default. The module's methods are available as instance methods, and the class's own methods take precedence — super inside the module reaches up to the class's superclass, not back to the class.
prepend inserts the module before the class in the ancestor chain. This means the module's method runs first, and calling super inside the module invokes the class's own version. This is the correct tool for method wrapping — instrumentation, logging, validating inputs before delegating to the real implementation. The Auditable#save above only works correctly with prepend, not include: with include, Order#save takes precedence and the module's save is never called unless super is explicitly added to Order#save.
extend mixes the module's methods as class methods rather than instance methods. It's how class-level DSLs like has_many, validates, and scope are implemented in Rails — they're instance methods on a module that gets extended into ActiveRecord::Base.
Hook methods: included and extended
When you include a module, Ruby calls the module's self.included(base) hook with the including class as the argument. This is how a single include can add both instance methods and class methods:
module Trackable
def self.included(base)
base.extend(ClassMethods)
base.instance_variable_set(:@tracked_fields, [])
end
module ClassMethods
def track(field)
@tracked_fields << field
end
def tracked_fields
@tracked_fields
end
end
def changes_for_tracked_fields
tracked_fields.each_with_object({}) do |field, changes|
changes[field] = send(field) if send(:"#{field}_changed?")
end
end
end
class Invoice
include Trackable
track :amount
track :status
end
Invoice.tracked_fields # => [:amount, :status]
ActiveSupport::Concern wraps this exact pattern and cleans up the boilerplate. The included do block runs in the context of the including class, and ClassMethods is automatically extended:
module Trackable
extend ActiveSupport::Concern
included do
@tracked_fields = []
end
module ClassMethods
def track(field)
@tracked_fields << field
end
end
end
If you're in a Rails codebase, use Concern. If you're building a standalone library without ActiveSupport, the self.included hook is the native equivalent.
The namespace use case
Modules have a second, unrelated job: namespacing. A module with no instance methods acts as a namespace container:
module Payments
class Gateway; end
class Webhook; end
class Invoice; end
module Fraud
class Detector; end
class RuleEngine; end
end
end
Keep this use case mentally separate from the mixin use case. A module that namespaces should not also be included anywhere. A module that gets mixed in should not also serve as a namespace. Conflating the two produces the kind of include Utils that nobody can reason about six months later.
Where mixins break down
State in modules is trouble. A module that reads or writes instance variables assumes something about the including class — that those variables exist, aren't used for something else, don't conflict with another included module. This is implicit coupling that doesn't show up until two modules both try to use @status:
module Publishable
def publish
@status = :published # assumes @status is available
end
end
module Archivable
def archive
@status = :archived # stomps on Publishable's variable
end
end
The fix is for each module to namespace its own state:
module Publishable
def publish
@publishable_status = :published
end
end
Verbose, but correct. Alternatively, push shared state into a dedicated object and inject it rather than relying on instance variables at all.
Module ordering determines behavior. When two included modules define the same method, the last one included wins:
class Report
include Loggable
include Auditable # Auditable#save wins if both define save
end
This is deterministic but fragile. Adding a new include at the top of a class can silently change which module's method runs. If two modules need to both intercept the same method, prepend with explicit super chains is the correct approach — each module calls super and the chain is explicit rather than implicit.
Mixins are not a substitute for service objects. The composition-over-inheritance argument gets misapplied as "extract all behavior into modules." A class with fifteen mixed-in modules is not better than a five-level inheritance hierarchy — it's just a different kind of mess. If a behavior is complex enough to warrant its own tests, its own state, and its own lifecycle, it belongs in a plain Ruby object, not a module.
The decision hierarchy
Use inheritance when the subclass genuinely is a specialization of the parent — AdminUser < User, SaasInvoice < Invoice. One level deep, two at most.
Use include for capability modules that add behavior without wrapping existing methods — Serializable, Comparable, Cacheable.
Use prepend when you need to wrap an existing method — instrumentation, authorization checks, input validation before delegation.
Use extend for class-level DSLs and configuration.
Use a plain object when the behavior has its own state, lifecycle, or test surface.
The ancestor chain is inspectable at any time with MyClass.ancestors. When module interactions get confusing, that's the first thing to read. The lookup order is always deterministic — you just have to look at it.