Composition: Building with Parts

David Morales David Morales
/
Three cards assembling into a single report dashboard.

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:

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
# ...
end

The 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"
end
end
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
# ...
end

This 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
# ...
end

When we call report.export_to(:pdf), Ruby traverses this path:

report.export_to(:pdf)
1. Report → defines export_to? No
2. Auditable → (last included) defines it? No
3. Cacheable → defines it? No
4. 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")
end
end

Each section is a simple object that knows how to render itself:

class TextSection
def initialize(data:)
@data = data
end
def render = @data.to_s
end
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")
end

What 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 TableSection

Report 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.render

A 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)
end
end

Creating 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.render

Using 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.

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:

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:

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:

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:

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 LineChartSection

Each mechanism is applied to the problem it solves best:

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 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

  1. When does behavior belong in a module instead of a superclass?

  1. A class and an included module both define the same method. Which one runs?

  1. A Report holds Section objects and calls render on each. What relationship is that?

  1. Why is composition the better fit for different report types (financial, executive…)?

  1. When is a plain duck type enough, with no module needed?