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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why Seoul's Startup Scene Is Thriving But Its Backend Talent Is Locked Up in Chaebols

Seoul's startup ecosystem has real momentum. The backend engineers who could staff it are mostly somewhere else.

Read more

Why Backend Developers Often Carry the Most Responsibility in a Team

Backend developers rarely get the spotlight, but they often hold the threads that keep an entire system running. Their work affects performance, reliability, and scalability.

Read more

Why Contractors Thrive When Given Autonomy, Not Office Orders

“Just be in the office from 9 to 6 and follow our process.” That’s usually where contractor performance starts losing its edge.

Read more

Early Signs a Software Project Is Headed for Disaster

Sometimes, you can feel a project slipping before it even starts shipping bugs. Recognizing the red flags early can save time, money, and a lot of headaches.

Read more