Practical object-oriented design for Ruby, drawn from Sandi Metz (Practical Object-Oriented Design in Ruby, 99 Bottles of OOP) and SOLID. Use it to keep change cheap, not to decorate code with patterns.
Pronounce POODR “pooder.”
Design exists so you can keep designing later. Judge a design by the cost of the next change, not by how clever it looks today.
The purpose of design is to reduce the cost of change.
Code that is easy to change is TRUE:
| Letter | Means | Test |
|---|---|---|
| Transparent | Consequences of a change are obvious in the code that changes | You can name what else breaks |
| Reasonable | Cost of a change matches the benefit | A small feature is a small diff |
| Usable | Existing code works in new contexts | You reuse it without editing it |
| Exemplary | The code teaches the next change | A newcomer copies the existing pattern |
A short procedure with no conditionals is easier to understand than an object model. OO adds messages, and messages add indirection. Pay for that indirection only when the problem will not stay a simple procedure.
Failure mode Metz calls FOO: chopping a procedural blob into classes without a real model. You hid the procedure and added hops. That is not OO.
Heuristics that force a conversation. Not laws.
| Rule | Limit |
|---|---|
| Class length | 100 lines |
| Method length | 5 lines |
| Parameters | 4 (an options hash still counts) |
| Controller action | instantiates one object |
| View | knows one instance variable |
Rule 0: break any rule if you can convince your pair why.
One-sentence test: describe the class without “and” or “or.” A conjunction means two reasons to change.
Question test: ask each public method as a question of the object. “Gear, what is your ratio?” is coherent. “Gear, what is your tire size?” means the tire does not belong on Gear.
One reason to change, meaning one actor or requirement, not one method.
# Two reasons: pricing policy and the payment vendor
class Order
def checkout
total = items.sum { |i| i.price_cents * i.quantity }
Stripe::Charge.create(amount: total, currency: "usd", source: token)
end
end
# Pricing stays. Charging is injected.
class Order
def initialize(items, gateway:)
@items = items
@gateway = gateway
end
def checkout
@gateway.charge(total_cents)
end
def total_cents
@items.sum { |i| i.price_cents * i.quantity }
end
endDo not split a cohesive 40-line class into FooAdder and FooFormatter until a second caller or a second variant exists.
Open for extension, closed for modification. Add behavior by adding a class, not by editing the policy.
# Closed: a new tender does not touch Checkout
class Checkout
def charge(tender, amount_cents)
tender.charge(amount_cents)
end
end
class Card
def charge(amount_cents)
Stripe::Charge.create(amount: amount_cents, source: token)
end
end
class Ach
def charge(amount_cents)
AchClient.debit(routing: routing, account: account, amount: amount_cents)
end
endThe case that picks a concrete belongs in a factory at the edge, not in the policy object. A case that selects an object is cheap. A case that contains the behavior is not.
Do not build the extension point until the axis of change is real. Premature Open/Closed is the wrong abstraction.
A subtype must be usable anywhere the supertype is expected, including guarantees about arguments, return values, exceptions, and side effects. Matching method names is not enough.
Square < Rectangle fails if setting width also sets height: callers of Rectangle were promised that width and height are independent.
Symptoms of a broken hierarchy:
- an override that raises “not supported”
- a stronger precondition or a weaker postcondition than the parent
- a caller that must
is_a?before sending a message
If you need the type check, it is not a subtype. Use a narrower role, or composition.
Clients should not depend on messages they do not send. In Ruby the interface is the set of messages, not a declared type.
A role is defined by its callers. #charge does not imply #refund unless this caller sends both.
Fat-interface smell: groups of methods used by different callers, or implementers that no-op half the methods.
High-level policy depends on a role. Low-level details implement the role. Bind the concrete at the edge.
An object depends on another when it knows any of:
- the other class’s name
- the message it intends to send
- the arguments that message requires
- the order of those arguments
# Depends on a class name and a constructor. Hard to fake.
class Checkout
def charge(amount)
StripeGateway.new.charge(amount)
end
end
# Depends on a message. The edge wires Stripe.
class Checkout
def initialize(gateway:)
@gateway = gateway
end
def charge(amount)
@gateway.charge(amount)
end
endInjection does not require a container. initialize is enough. Depend on things that change less often than you do: roles change less than concrete classes.
| Technique | Use when |
|---|---|
| Inject the collaborator | The policy object was constructing what it uses |
Isolate Something.new in one method or factory |
Creation and use are tangled |
| Wrap a third-party API in your own object | The vendor’s interface is awkward or unstable |
| Hide instance variables behind methods | Callers, including self, are reaching into data |
| Hide data structures | A hash or array shape is leaking |
Always send messages to get data, even inside the owning class. Reaching into @items or customer[:address][:city] from several places freezes the structure.
Public methods should:
- be explicitly public (
privateeverything else) - say what, not how
- have names unlikely to change
- take keyword arguments when order would otherwise matter
Honor other objects’ public interfaces. Depending on a private method of a gem is debt.
Law of Demeter: talk only to yourself, your arguments, objects you create, and your direct collaborators. Do not chain through neighbors.
# Breaks when Customer or Address is renamed or reshaped
order.customer.address.city
# The chain, if it remains, lives inside Order
order.ship_to_cityA Demeter violation means the message is aimed at the wrong object.
| Relationship | Mechanism | Example |
|---|---|---|
| is-a, shared template, substitutable | Inheritance | BottleNumber1 < BottleNumber overriding one step |
| behaves-like-a | Duck type / module | Anything that responds to #charge |
| has-a | Composition | Order holds a gateway |
Inheritance rules:
- subclasses inherit only what they need
- template method in the parent, variant steps overridden
superis often a smell that the abstraction is wrong- abstract shared behavior only after the variants exist
Duck typing replaces case on class and is_a? / kind_of?. Concrete code is easy to read and costly to extend. Abstract code is harder to read and cheaper to extend. Pick based on how likely that axis is to change.
# Class check: every new preparer edits Trip
def prepare(preparers)
preparers.each do |p|
case p
when Mechanic then p.prepare_bicycles(bicycles)
when TripCoordinator then p.buy_food(customers)
end
end
end
# Duck: Trip sends one message
def prepare(preparers)
preparers.each { |p| p.prepare_trip(self) }
end- Get it working in shameless green: concrete, duplicated, tested.
- One-sentence test. Split on “and.”
- Who constructs the collaborator? If the policy does, invert it.
- Is the branch on type? Type becomes a role. Data stays a conditional until a second shape appears.
- Can every stand-in keep the caller’s assumptions? If not, it is not a subtype.
- Is the role only the messages this caller sends?
- Flock the extraction: most alike, smallest difference, simplest change that removes it. Tests stay green.
Flocking rules:
- Select the things that are most alike.
- Find the smallest difference between them.
- Make the simplest change that removes that difference.
Duplication is far cheaper than the wrong abstraction. When the abstraction is wrong, the fastest way forward is back.
Extract on the third similar occurrence, once you can name the difference, not just the sameness. DRYing difference is more valuable than DRYing sameness, and also easier to get wrong.
Test the public interface.
| Message kind | Assert |
|---|---|
| Incoming query | the return value |
| Incoming command | the visible side effect |
| Outgoing command | that the message was sent (injected double) |
| Outgoing query | usually nothing; it has no side effect |
Do not test private methods. Do not assert on every outgoing query. Tests coupled to implementation make the refactor you just enabled expensive again.
Prefer a fake that speaks the role (FakeGateway#charge) over stubbing Stripe::Charge.create deep inside a class you do not own.
| You see | Likely move |
|---|---|
| Class description needs “and” | Split by reason to change |
Foo.new inside the method that uses it |
Inject, or isolate construction |
case on class, is_a?, kind_of? |
Duck / role |
object.a.b.c |
Demeter; ask the neighbor |
| Subclass raises “not supported” | Broken hierarchy; composition or a narrower role |
Repeated super |
Inheritance abstraction is wrong |
| Two similar branches, no third shape | Leave the duplication |
| Options hash of four unrelated keys | Too many parameters; maybe two objects |
| Controller builds three services | One object per action; it builds the rest |
| View with five instance variables | One presenter |
- You don’t send messages because you have objects. You have objects because you send messages.
- Prefer duplication over the wrong abstraction.
- It’s not what an object is that matters. It’s what it does.
- Make the smallest change that could possibly work, then refactor under tests.
- Loose coupling and object ignorance are the same idea: each object should know as little as possible about the others.
- The rules are there to make a hidden responsibility visible. The skill is knowing which change is coming, and arranging the code so that change touches one place.