Skip to content

Instantly share code, notes, and snippets.

@ryenski
Created October 9, 2026 16:56
Show Gist options
  • Select an option

  • Save ryenski/637d78dc2458b9815a7e1b0e0c80221f to your computer and use it in GitHub Desktop.

Select an option

Save ryenski/637d78dc2458b9815a7e1b0e0c80221f to your computer and use it in GitHub Desktop.
Ruby OOP Cheat Cheat

Ruby OOP Cheat Sheet

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


The point

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.


Metz rules of thumb

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.


SOLID, Ruby-shaped

S — Single Responsibility

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
end

Do not split a cohesive 40-line class into FooAdder and FooFormatter until a second caller or a second variant exists.

O — Open/Closed

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
end

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

L — Liskov Substitution

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.

I — Interface Segregation

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.

D — Dependency Inversion

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:

  1. the other class’s name
  2. the message it intends to send
  3. the arguments that message requires
  4. 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
end

Injection does not require a container. initialize is enough. Depend on things that change less often than you do: roles change less than concrete classes.


Dependencies: isolate them

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.


Interfaces and the Law of Demeter

Public methods should:

  • be explicitly public (private everything 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_city

A Demeter violation means the message is aimed at the wrong object.


Choosing a mechanism

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
  • super is 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

Refactoring sequence

  1. Get it working in shameless green: concrete, duplicated, tested.
  2. One-sentence test. Split on “and.”
  3. Who constructs the collaborator? If the policy does, invert it.
  4. Is the branch on type? Type becomes a role. Data stays a conditional until a second shape appears.
  5. Can every stand-in keep the caller’s assumptions? If not, it is not a subtype.
  6. Is the role only the messages this caller sends?
  7. Flock the extraction: most alike, smallest difference, simplest change that removes it. Tests stay green.

Flocking rules:

  1. Select the things that are most alike.
  2. Find the smallest difference between them.
  3. 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.


Tests that do not freeze the design

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.


Smell to move

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

Lines worth saying

  • 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment