Composition: Building with Parts
When an object needs behavior that already exists elsewhere, most people’s default answer is inheritance: create a superclass and hang the classes that share code from it. It works, but inheritance solves a very specific problem (the specialization of a type) and gets expensive as soon as you push it beyond its intended use.
Ruby offers two other tools for the cases where inheritance doesn’t fit:
- What happens when a role with real implementation is shared by classes that have no relationship with each other? That’s what modules are for.
- How do you build a complex object out of simpler ones, instead of growing a deeper and deeper hierarchy? That’s what composition is for.
And once you have three tools on the table (inheritance, modules, and composition), you need to choose the right one for each problem. The clue, as we’ll see, always lies in the type of relationship: is-a, behaves-like-a, or has-a.
We’ll explore all of this by designing a report generator. A report is built from parts: an introductory text, charts, tables, and a summary. Composition appears naturally, modules capture cross-cutting capabilities like “being exportable,” and the distinction between is-a, behaves-like-a, and has-a guides every design decision.
Modules as Roles
Is-a vs. Behaves-Like-a
Inheritance models an is-a relationship. A Refund is a Transaction: a more concrete version of its parent. That relationship is the only legitimate reason to use inheritance.
But there is behavior that is not a type of anything. It’s something an object knows how to do, regardless of what it is. For example, “being exportable” is not a type: a report can be exported, a chart can be exported, an invoice can be exported. Those three objects share nothing in their essence. What they share is a capability.
We call that capability a role. The object doesn’t is-a exportable; it behaves-like-a Exportable. And in Ruby, a role that also contains shared behavior lives in a module.
The Antipattern: Duplication Across Unrelated Classes
Imagine that both reports and charts need to export to PDF or CSV. A first attempt, without too much thought, ends up duplicating the same logic in two classes:
class Report def export_to(format) case format when :pdf then PdfRenderer.generate(render) when :csv then CsvRenderer.generate(rows) else raise ArgumentError, "Unsupported format: #{format}" end end # ...end
class Chart def export_to(format) case format when :pdf then PdfRenderer.generate(render) when :csv then CsvRenderer.generate(data_points) else raise ArgumentError, "Unsupported format: #{format}" end end # ...endThe export logic is practically identical, repeated in two places. Anyone who knows inheritance will be tempted to create a shared superclass to reuse that code. But Report and Chart have no common ancestor that makes sense: a chart is not a report, and vice versa. Forcing a shared superclass just to reuse the export method means inheriting for the wrong reason: exactly the same mistake as making EmailNotification inherit from User just because both have an email.
What we’re looking at is a hidden role.
The Solution: Extracting the Role into a Module
We pull the shared behavior into a module and include it in both classes:
module Exportable def export_to(format) case format when :pdf then PdfRenderer.generate(export_content) when :csv then CsvRenderer.generate(export_rows) else raise ArgumentError, "Unsupported format: #{format}" end end
# Hooks: every includer must provide these def export_content raise NotImplementedError, "#{self.class} must implement export_content" end
def export_rows raise NotImplementedError, "#{self.class} must implement export_rows" endend
class Report include Exportable
def export_content = render def export_rows = sections.flat_map(&:export_rows) # ...end
class Chart include Exportable
def export_content = render def export_rows = data_points # ...endThis is the Template Method pattern with hooks (which also applies to inheritance). The module defines the algorithm (export_to) and delegates to methods that each includer specializes (export_content, export_rows). This is where the rule applies: a module must implement every message it sends, even if only to raise an explicit error. If someone creates an Invoice that includes Exportable tomorrow and forgets export_rows, the NotImplementedError will tell them exactly what’s missing and where, instead of a cryptic NoMethodError far from where the real problem is.
The difference from inheritance is conceptual, not technical. With inheritance you declare that an object is-a certain type. With a module you declare that an object behaves-like-a certain role. Both share the same mechanics (automatic message delegation), but they mean different things.
It’s worth being clear about the tradeoff. By extracting the role into a module, you’re not just eliminating duplication: you’re creating an implicit contract between all the classes that include it. The dependency doesn’t disappear; it changes shape. It’s less visible than the one in an inheritance hierarchy (it doesn’t appear in the class X < Y signature), but it’s there just the same: each includer promises to fulfill the hooks the module expects, and a change to that contract affects all of them at once. That’s why a module isn’t free, and it’s only worth it when the role is real and shared by multiple classes.
How Ruby Finds Module Methods
When a class includes a module, its methods enter the method lookup path, right after the class’s own methods and before those of the superclass.
This has two practical consequences worth keeping in mind. First: if the class defines a method that’s also in the module, the class wins, because it’s searched first. Second: if you include multiple modules, the last one included is searched first.
Let’s say Report includes three roles:
class Report include Exportable include Cacheable include Auditable # ...endWhen we call report.export_to(:pdf), Ruby traverses this path:
report.export_to(:pdf)
1. Report → defines export_to? No2. Auditable → (last included) defines it? No3. Cacheable → defines it? No4. Exportable → defines it? Yes ✓ (executed here)5. Object → (never reached)Class first, then modules from bottom to top in reverse order of inclusion, then the superclass, and so on up to the top. If nothing responds, Ruby gives it a second chance by calling method_missing.
This path isn’t just theoretical: you can see it at any time by calling Report.ancestors, which returns the exact list of classes and modules in the order they’re searched. When a method behaves unexpectedly, that’s the first place to look.
The Structural Advantage: Many Roles, One Parent
Notice what we just did. Report plays three roles at the same time: Exportable, Cacheable, and Auditable. And at the same time, Chart (a class with no relationship to Report) also plays Exportable.
Here’s the key: inheritance gives you one parent. If “exportable,” “cacheable,” and “auditable” were superclasses, you’d have to pick which one is Report’s parent, and the other two would be left out. Modules don’t compete for that single slot. A class can include as many modules as roles it plays, and each role can be spread across classes that share no hierarchy.
That’s why, when behavior crosses the hierarchy (objects of different types need it), its home is a module, not a forced superclass.
Rules for Writing Module Code
Since the mechanics are the same as inheritance, the rules for writing inheritable code apply equally to modules. Two are especially important:
Insist on the abstraction. All code in the module must apply to every includer. If an includer needed to override a module method to raise a “not supported” error, that’s a signal that the role doesn’t really fit and the class shouldn’t include the module. An object that declares “I don’t do that” is, in effect, declaring “I’m not that.”
Honor the contract. Every Exportable must behave like an Exportable. This is the Liskov Substitution Principle applied to roles: any object that plays the role must be interchangeable with any other that plays it, from the perspective of the caller.
And, as with inheritance, prefer hooks over forcing includers to call super, and keep things shallow. Modules that include other modules, that override each other’s methods, inside deep hierarchies, produce lookup paths that are impossible to follow. Modules are powerful, but also dangerous when misused.
Composition: The Has-a Relationship
Modules solve how to share a role. Composition solves something different: how to build a complex object out of simpler ones.
The Whole Is More Than the Sum of Its Parts
A report is not a section, not a chart, not a table. A report has sections. That has-a relationship is composition: combining simple, independent parts into a more complex whole, where the whole communicates with its parts through an interface.
A song is a list of independent notes, but it’s not the notes, it’s something more. A report will be more than its sections in the same way.
A Report Composed of Sections
Let’s start with the simplest possible thing. A Report holds its sections and delegates to them for rendering:
class Report attr_reader :title, :sections
def initialize(title:, sections:) @title = title @sections = sections end
def render ["# #{title}", *sections.map(&:render)].join("\n\n") endendEach section is a simple object that knows how to render itself:
class TextSection def initialize(data:) @data = data end
def render = @data.to_send
class ChartSection def initialize(data:) @data = data end
def render = "[chart] #{@data}"end
class TableSection def initialize(data:) @data = data # array of rows end
def render = @data.map { |row| row.join(" | ") }.join("\n")endWhat matters: Report doesn’t know what class each section is. It only knows that each one responds to render. Adding a new type of section means writing a class that responds to render; Report is not modified (Open/Closed Principle).
Drawn out, the relationship is straightforward:
Report | has-a (1..*) v Section <- role: any object that responds to #render ^ | played by +--------------+--------------+ | | | TextSection ChartSection TableSectionReport depends on the Section role, not on the three concrete classes. The upward arrows are a reminder that those classes don’t form a hierarchy: they’re independent parts that share one single thing: responding to render.
Building a report means snapping parts together:
sales_report = Report.new( title: "Q2 Sales", sections: [ TextSection.new(data: "Strong growth across all regions."), ChartSection.new(data: :revenue_by_quarter), TableSection.new(data: [["Region", "Sales"], ["EU", "1.2M"]]) ])
puts sales_report.renderA Factory for Assembling Configurations
We have the parts, but a question arises: who knows that a financial report includes a summary, a revenue chart, and a balance sheet? If that knowledge is scattered across the application (every place that creates a financial report repeats the same list of sections), it leaks and duplicates.
The solution is to centralize that knowledge in a factory: an object whose only job is to manufacture other objects. In our case, it builds reports from a catalog of configurations:
module ReportFactory SECTION_CLASSES = { text: TextSection, chart: ChartSection, table: TableSection }
CONFIGS = { financial: [ [:text, "Financial summary for the quarter"], [:chart, :revenue_by_quarter], [:table, [["Assets", "120k"], ["Liabilities", "80k"]]] ], operational: [ [:chart, :tickets_resolved], [:table, [["SLA", "98%"], ["MTTR", "2h"]]] ], executive: [ [:text, "Executive summary"], [:chart, :revenue_by_quarter], [:chart, :tickets_resolved] ] }
def self.build(type:, title:) sections = CONFIGS.fetch(type).map do |section_type, data| build_section(section_type, data) end
Report.new(title: title, sections: sections) end
def self.build_section(section_type, data) SECTION_CLASSES.fetch(section_type).new(data: data) endendCreating any report is now trivial, and all the knowledge of what each type contains lives in one place:
report = ReportFactory.build(type: :financial, title: "Q2 Finance")puts report.renderUsing fetch instead of [] is intentional: if someone asks for a report type or section type that doesn’t exist, we want an immediate and clear error, not a nil that blows up three steps down the line.
And here’s the payoff. Adding a new report type (:marketing) is one entry in CONFIGS. Adding a new section type (:kpi) is one class and one entry in SECTION_CLASSES. No existing code changes.
Composition vs. Deep Hierarchies
Without composition, report variety would be modeled with inheritance: FinancialReport < Report, OperationalReport < Report, ExecutiveReport < Report… That works until someone asks for a report that mixes financial and operational sections. Combining the qualities of two subclasses into a single object isn’t possible with single inheritance, because each class has only one parent.
With composition, “mixing” isn’t a special problem at all: it’s just another list of sections. No new class needed. Composition keeps the design flat and flexible, instead of the rigidity of inheritance.
The Decision: Inheritance, Modules, or Composition
You now have three tools. The skill is in recognizing which one each problem calls for, and the clue is in the type of relationship.
- Is-a → inheritance. Genuine specialization, with a shallow, stable hierarchy. A
Refundis-aTransaction. - Behaves-like-a → module (or duck type). A cross-cutting role shared by unrelated objects. A module if the role carries behavior (like
Exportable); a plain duck type if it’s only an interface. - Has-a → composition. A whole built from parts. A
Reporthas sections.
The Cost of Each
Inheritance gives you message delegation in exchange for organizing objects in a hierarchy. A change at the top propagates downward with very little code: enormous leverage. But that lever cuts both ways. The hierarchy is rigid, dependencies run deep, there’s only one parent, and when the model is wrong, fixing it is expensive and errors surface far from where they were introduced.
Composition gives you small, transparent, independent, and pluggable parts, immune to the side effects of changes in a hierarchy that doesn’t exist. The cost is that delegation is explicit (the composed object has to know who to ask for each thing) and that, even if each part is easy to understand on its own, the whole can be less obvious.
Modules share almost all the costs and benefits of inheritance, because they use the same delegation mechanics and require the same rules for writing shared code. The difference comes down to two points:
- In their favor: since they don’t take up the parent slot, a class can play multiple roles at once, giving them structural flexibility.
- Against: that same dependency is more invisible, because it doesn’t appear in the class signature (
class X < Yis visible; anincludecan go more unnoticed), which makes a broken contract harder to track down.
A duck type is the cheapest of all: it adds no code or dependencies, just an agreement about which messages are understood. But it’s not enough when there’s behavior to share.
When facing a problem that composition can solve, lean toward it. It’s the starting point because it has fewer built-in dependencies than inheritance. From there, let the relationship decide:
- If what multiple objects share is just an agreement about which messages they understand, a duck type is enough.
- If there’s also implementation that would be repeated, move it up to a module.
- Reserve inheritance for the most demanding case: a genuine, shallow, and stable specialization. Inheritance is the hardest to undo when you get it wrong, so it’s the one you need to be most able to defend before choosing it.
A Concrete Case: Does FinancialReport Inherit or Compose?
Should FinancialReport inherit from Report, or be a Report composed with financial parts?
The way to decide is to ask what varies across report types. And what varies is which sections each one contains. That’s configuration, data. The report machinery (rendering, exporting, caching) is identical across all types.
Inheritance would require one class per type, and the combinations explode: FinancialReport, ExecutiveReport, or even FinancialExecutiveReport. Composition has a single Report class and many configurations: a financial report is a Report that the factory assembles with financial sections. Composition wins, hands down.
Where would inheritance fit? In a much smaller and more stable place. Imagine ChartSection has variants (BarChartSection, LineChartSection) that share almost everything and differ only in a rendering detail. Here inheritance would be the right solution, for concrete reasons:
- There’s a real is-a (a bar chart is a chart, not just something that resembles one).
- The hierarchy is shallow (a single level).
- It’s stable (you don’t expect “being a chart” to change meaning anytime soon).
These are exactly the conditions that make inheritance cheap and safe. And notice that those subclasses still plug into the Section role via duck typing. All three mechanisms work together.
All Three Together
Here’s the final picture of the architecture:
Reportis built with sections through composition.- Some sections share behavior through a small inheritance hierarchy.
- Both
ReportandChart, with no relationship between them, play theExportablerole through a module.
In a diagram, with each relationship labeled by its mechanism:
Exportable (module: behaves-like-a) | included by +-------+-------+ | | Report Chart | | has-a (composition) v Section (role: duck type) ^ | played by +--+----------+--------------+ | | | TextSection TableSection ChartSection | | is-a (inheritance) v +------+------+ | | BarChartSection LineChartSectionEach mechanism is applied to the problem it solves best:
- What crosses unrelated classes goes to a module.
- What is assembled from parts goes to composition.
- What is a genuine and stable specialization goes to inheritance.
Conclusion
SOLID principles describe the what; inheritance, modules, and composition are the how. They are the concrete mechanics through which Ruby makes those principles tangible.
- The Single Responsibility Principle shows up when each section does one thing (for example,
Reportorchestrates, and the factory assembles). - The Open/Closed Principle is built into the mechanics: new report types, section types, or export formats are added without touching existing code.
- The Liskov Substitution Principle is what makes any
Sectioninterchangeable with another and anyExportablebehave as one. - The Dependency Inversion Principle is what
Reportdoes by depending on theSectionrole (an abstraction) rather than on the concrete class of each section.
The starting point is reading the relationship in front of you: does this object is-a something else, has-a something else, or behaves-like-a something else? Answer that question and the mechanism picks itself.
In the end, a report is more than its sections, just like a song is more than its notes. Composing software means using simple parts that, combined, become something none of them was on its own.
Test your knowledge
-
When does behavior belong in a module instead of a superclass?
-
A class and an included module both define the same method. Which one runs?
-
A
ReportholdsSectionobjects and callsrenderon each. What relationship is that?
-
Why is composition the better fit for different report types (financial, executive…)?
-
When is a plain duck type enough, with no module needed?