Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Designing with Composition

“Favor composition over inheritance” has been the standard advice since the Gang of Four wrote it down in 1994, and it has been ignored for thirty years for an understandable reason: in a language with inheritance, inheritance is easier. It is one keyword. Composition means declaring a field, writing delegating methods, and deciding what to expose, which is more typing on the first day and better on every day after.

Rust removes the easy option. There is no inheritance to fall back on, so every design that would have been a hierarchy has to be something else. This chapter is about what that something else is, and it is the most direct answer this book gives to The Object-Oriented Thought Process, whose two longest chapters are about inheritance and composition and how to choose.

What a hierarchy was doing

Take a small hierarchy of the kind that appears in every business system.

An abstract Notifier holds a recipient and a retry count, provides a concrete send that formats a message and calls an abstract deliver, and is extended by EmailNotifier and SmsNotifier, each supplying deliver. Perhaps SmsNotifier also overrides format to truncate.

Three separate things are happening, and untangling them is the work.

The recipient and retry count are shared data. Every notifier has them, and they are identical in every subclass.

The send method is shared behavior built on a hole. It is the same for everyone, and it calls something each subclass supplies.

deliver is varying behavior — the reason there is a hierarchy at all.

Rust handles those three with three different constructs, and the resulting design is clearer than the original because the three are visible rather than merged.

Shared data is a field

#![allow(unused)]
fn main() {
struct Recipient {
    name: String,
    address: String,
}

struct DeliverySettings {
    retries: u8,
    timeout_seconds: u32,
}
}

Data that several types have in common goes in a struct that those types hold. Not inherited, held. The relationship is has-a, and it is written out:

#![allow(unused)]
fn main() {
struct Recipient { name: String, address: String }
struct DeliverySettings { retries: u8, timeout_seconds: u32 }
struct Notification {
    recipient: Recipient,
    settings: DeliverySettings,
    body: String,
}
}

The cost compared to inheritance is one extra level of naming: notification.recipient.name rather than notification.name. The benefit is that Recipient is now a type, which means it can be validated once, passed on its own, tested on its own, and reused by things that are not notifications at all. Most of the time the extra level is also more accurate — a notification is not a person, and writing notification.name always was a small lie about what the field meant.

Varying behavior is a trait

The part that differed between subclasses becomes a trait with one method:

#![allow(unused)]
fn main() {
struct Recipient { name: String, address: String }
trait Channel {
    fn deliver(&self, to: &Recipient, body: &str) -> Result<(), String>;
    fn label(&self) -> &'static str;
}

struct Email {
    smtp_host: String,
}

struct Sms {
    gateway_id: u32,
}

impl Channel for Email {
    fn deliver(&self, to: &Recipient, body: &str) -> Result<(), String> {
        println!("[{}] to {} <{}>: {}", self.smtp_host, to.name, to.address, body);
        Ok(())
    }
    fn label(&self) -> &'static str {
        "email"
    }
}

impl Channel for Sms {
    fn deliver(&self, to: &Recipient, body: &str) -> Result<(), String> {
        let truncated: String = body.chars().take(160).collect();
        println!("[gateway {}] to {}: {}", self.gateway_id, to.address, truncated);
        Ok(())
    }
    fn label(&self) -> &'static str {
        "sms"
    }
}
}

Note that Email and Sms carry their own configuration. Under inheritance, an smtp_host field on EmailNotifier and a gateway_id on SmsNotifier sit alongside the inherited recipient and retry count in one object, and nothing distinguishes the channel’s configuration from the message’s. Here they are separate types and the separation is visible.

Shared behavior with a hole in it

The send method — same for everyone, calling into the part that varies — has two good homes in Rust, and choosing between them is a real decision.

The first is a default method on the trait, which is the direct translation of a template method:

#![allow(unused)]
fn main() {
struct Recipient { name: String, address: String }
trait ChannelBase { fn deliver(&self, to: &Recipient, body: &str) -> Result<(), String>; }
trait Channel {
    fn deliver(&self, to: &Recipient, body: &str) -> Result<(), String>;

    fn send_with_retry(&self, to: &Recipient, body: &str, retries: u8) -> Result<(), String> {
        let mut attempt = 0;
        loop {
            match self.deliver(to, body) {
                Ok(()) => return Ok(()),
                Err(error) if attempt < retries => {
                    attempt += 1;
                    println!("retry {attempt} after: {error}");
                }
                Err(error) => return Err(error),
            }
        }
    }
}
}

The second is a separate type that holds a channel and adds the behavior around it:

#![allow(unused)]
fn main() {
struct Recipient { name: String, address: String }
trait Channel { fn deliver(&self, to: &Recipient, body: &str) -> Result<(), String>; fn label(&self) -> &'static str; }
struct Notifier {
    channel: Box<dyn Channel>,
    retries: u8,
}

impl Notifier {
    fn new(channel: Box<dyn Channel>, retries: u8) -> Notifier {
        Notifier { channel, retries }
    }

    fn send(&self, to: &Recipient, body: &str) -> Result<(), String> {
        let mut attempt = 0;
        loop {
            match self.channel.deliver(to, body) {
                Ok(()) => return Ok(()),
                Err(error) if attempt < self.retries => {
                    attempt += 1;
                    println!("retry {attempt} on {}: {error}", self.channel.label());
                }
                Err(error) => return Err(error),
            }
        }
    }
}
}

The default-method version keeps everything in one trait and gives the behavior to every implementor automatically. The wrapper version separates the retry policy from the delivery mechanism entirely, which means the policy can be tested against a stub channel, swapped for a different policy, or applied to channels that were written without knowing it exists.

The wrapper is usually the better design, and the reason is worth stating plainly: retrying is not something a channel is, it is something done to a channel. Putting it on the trait makes every implementor carry a policy decision that belongs to the caller. Default methods are best kept for behavior that genuinely belongs to the capability — convenience methods derived from the required ones, like Iterator’s adapters — rather than for policy.

Putting the pieces together:

struct Recipient {
    name: String,
    address: String,
}

trait Channel {
    fn deliver(&self, to: &Recipient, body: &str) -> Result<(), String>;
    fn label(&self) -> &'static str;
}

struct Email;
struct Sms;

impl Channel for Email {
    fn deliver(&self, to: &Recipient, body: &str) -> Result<(), String> {
        println!("email to {} <{}>: {}", to.name, to.address, body);
        Ok(())
    }
    fn label(&self) -> &'static str { "email" }
}

impl Channel for Sms {
    fn deliver(&self, to: &Recipient, body: &str) -> Result<(), String> {
        println!("sms to {}: {}", to.address, &body[..body.len().min(160)]);
        Ok(())
    }
    fn label(&self) -> &'static str { "sms" }
}

struct Notifier {
    channel: Box<dyn Channel>,
    retries: u8,
}

impl Notifier {
    fn send(&self, to: &Recipient, body: &str) -> Result<(), String> {
        println!("(via {}, up to {} retries)", self.channel.label(), self.retries);
        self.channel.deliver(to, body)
    }
}

fn main() {
    let recipient = Recipient {
        name: String::from("Ada"),
        address: String::from("ada@example.com"),
    };

    for channel in [Box::new(Email) as Box<dyn Channel>, Box::new(Sms)] {
        let notifier = Notifier { channel, retries: 2 };
        let _ = notifier.send(&recipient, "your invoice is ready");
    }
}

Four small types, each of which can be understood, tested and changed alone. The hierarchy it replaces had two classes and one of them was abstract.

When the set is closed, use an enum

Trait objects are the right answer when new implementations may arrive from anywhere. When the set of alternatives is fixed and known, an enum is simpler and gives you exhaustiveness checking back:

#![allow(unused)]
fn main() {
struct Recipient { name: String, address: String }
enum Channel {
    Email { smtp_host: String },
    Sms { gateway_id: u32 },
    Webhook { url: String, secret: String },
}

impl Channel {
    fn deliver(&self, to: &Recipient, body: &str) -> Result<(), String> {
        match self {
            Channel::Email { smtp_host } => {
                println!("[{smtp_host}] {} {body}", to.address);
                Ok(())
            }
            Channel::Sms { gateway_id } => {
                println!("[sms {gateway_id}] {} {body}", to.address);
                Ok(())
            }
            Channel::Webhook { url, .. } => {
                println!("[POST {url}] {body}");
                Ok(())
            }
        }
    }
}
}

No boxing, no dynamic dispatch, no trait, and adding a fourth channel produces a compile error at every match that needs updating. Chapter 7 laid out the trade: enums make adding operations easy and adding cases expensive, traits the reverse. For a set of channels that ships with the application, the enum is usually right. For a set that third parties extend, it cannot be.

Coming from an object-oriented language

The instinct that an enum is a primitive thing and a class hierarchy is the grown-up solution is worth examining. A sealed hierarchy with a visitor is a laborious reconstruction of what an enum and a match do directly, and the reason Java grew sealed classes and pattern matching in recent versions is that the reconstruction was not paying.

Delegation is explicit, and that is the point

Composition’s reputation for verbosity comes from delegation. If Notifier holds a Channel and callers need the label, Notifier writes:

#![allow(unused)]
fn main() {
trait Channel { fn label(&self) -> &'static str; }
struct Notifier { channel: Box<dyn Channel> }
impl Notifier {
    fn label(&self) -> &'static str {
        self.channel.label()
    }
}
}

Three lines to forward one method, where inheritance would have given it for free. Rust offers no delegation syntax, and proposals for one have been debated for years.

The verbosity is a real cost and it buys something specific: the public surface of Notifier is a list you wrote, rather than everything its parts happen to expose. A class inheriting from a base class inherits its entire public interface, including the methods that make no sense on the subclass, and the famous examples — Stack extends Vector, so you can insert into the middle of a stack — are this failure. With delegation, a method exists on the outer type because someone decided it should.

In practice the amount of forwarding stays small, because a composed type usually exposes a different interface rather than the same one. If you find yourself forwarding fifteen methods unchanged, that is a signal: either the wrapper is not adding anything and callers should hold the inner type directly, or the wrapper wants to expose the inner type through an accessor and let callers use it.

What you should not do is reach for Deref to get the methods automatically. It looks like it works and produces a type whose methods appear and disappear depending on whether the call goes through method resolution or trait resolution, which is a worse debugging experience than typing three lines.

Behavior as a parameter

The lightest form of composition is passing behavior in as a closure, and Rust programmers use it far more than an object-oriented programmer expects, because there is no ceremony:

struct Report {
    rows: Vec<(String, i64)>,
}

impl Report {
    fn render(&self, format_amount: impl Fn(i64) -> String) -> String {
        self.rows
            .iter()
            .map(|(label, amount)| format!("{label}: {}", format_amount(*amount)))
            .collect::<Vec<_>>()
            .join("\n")
    }
}

fn main() {
    let report = Report {
        rows: vec![
            (String::from("subtotal"), 12_50),
            (String::from("tax"), 1_06),
        ],
    };

    println!("{}", report.render(|cents| format!("${}.{:02}", cents / 100, cents % 100)));
    println!("{}", report.render(|cents| cents.to_string()));
}

In an object-oriented design this would be a Formatter interface, one or two implementing classes, and a constructor parameter. Here it is a closure. The trait-and-implementations version is worth its weight when the behavior has state, several methods, or needs a name for its own sake; when it is one function, a closure says so.

Conclusion

Inheritance merged shared data, shared behavior and varying behavior into one construct. Composition separates them: shared data is a field of a held type, varying behavior is a trait, and shared behavior is either a default method on that trait or — usually better — a wrapper type that holds an implementation and adds something around it.

A closed set of alternatives is an enum, with exhaustiveness checking as the reward. An open set is a trait object. One function’s worth of varying behavior is a closure and needs no type at all.

Delegation has to be written out, which is the cost, and the return is that a composed type’s public interface is chosen rather than inherited. When the forwarding grows large, that is information about the design rather than a reason to look for a shortcut.

The next chapter deals with the case this one has been avoiding: designs where the ownership tree genuinely is not a tree, and several parts of a program need to share the same mutable thing.

Questions to think through

Take a class hierarchy you know with three or more levels. For each level, separate what it contributes into shared data, shared behavior, and varying behavior. Does any level contribute nothing but a name?

The chapter argues that retry policy belongs in a wrapper rather than on the channel trait. Find a method on a base class in your own code that is policy rather than capability. What would hold it instead?

Stack extends Vector exposes methods that break the stack’s contract. Find an inherited method in your own code that should not exist on the subclass. What currently prevents someone from calling it?