DEV Community

Ian Johnson
Ian Johnson

Posted on • Originally published at tacoda.Medium on

OOP Principles That Will Take You Far

The code was supposed to be a small change. Add a discount code field, run the existing total through it, update the invoice. Two hours, tops.

Three days later, I had touched seventeen files. The Order class knew about the database, the email service, the audit log, and the printer. Every change rippled. Every test required a small army of mocks. The discount code worked, but I left the code worse than I found it.

That experience, repeated enough times, is how you learn to care about design. Not the abstract kind. The kind you reach for at three in the afternoon when a ticket is bigger than it looked.

The principles below have been around for decades. They are not novel. They survive because they keep paying out. Most of them push you toward the same target from different angles: code that does one thing, says what it means, and changes without panic.

SOLID, in one page

SOLID is five rules, often taught poorly. Here they are with the marketing stripped out.

Single Responsibility. A class has one reason to change. If you find yourself saying “this class handles X and Y,” that’s two classes. The Order from my opening paragraph was a poster child: pricing logic and persistence and notification, all in one bag.

Open/Closed. A class is open for extension and closed for modification. In practice this means: when a new case shows up, you add a new class rather than threading another if through an old one. Polymorphism is the mechanism.

Liskov Substitution. A subtype should work anywhere the parent works without surprising the caller. If Square extends Rectangle but breaks every test that sets width and height independently, the inheritance was a lie. Most violations come from forcing an is-a relationship that the domain didn’t actually have.

Interface Segregation. Many small interfaces beat one fat one. A Reader and a Writer are more useful than a ReaderWriterCloserSeekerScanner. Callers should depend only on what they use.

Dependency Inversion. High-level code should not depend on low-level code; both should depend on abstractions. The pricing engine doesn’t import the MySQL driver. It accepts a PriceRepository, and the wiring is done at the edges.

The five letters are easier to remember as one sentence: small classes with one reason to change, depending on narrow interfaces, composed from the outside.

Program to an interface, not an implementation

This one shows up in the Gang of Four book and gets quoted into the ground, but the practical version is simple.

When you write def send(notifier), you can pass anything that responds to notify. An email sender, a Slack client, a queue writer, a fake for tests. When you write def send(mailer: EmailMailer), you have nailed yourself to one implementation. The first version costs you nothing extra and buys you every future swap for free.

This is not a license to invent interfaces for everything. One implementation, no test double, no anticipated variation? Don’t make the interface yet. The principle is about the shape of the dependency, not the number of files.

Tell, don’t ask

Old principle, still underrated. If you find yourself pulling data out of an object, making a decision, and pushing the result back in, you have written the object’s logic on the outside. Move it back in.

# Asking
if account.balance >= amount
  account.balance -= amount
end

# Telling
account.withdraw(amount)
Enter fullscreen mode Exit fullscreen mode

The second version hides the rule. If overdrafts get allowed next quarter, one class changes. The first version is the same logic, scattered across every caller.

A related rule is the Law of Demeter: a method should talk to its parameters, its fields, objects it creates, and not much else. Long a.b.c.d.e chains are a smell that something in the middle should be doing the work for you.

DRY, but not the way you were taught

DRY is “don’t repeat yourself.” The popular reading is “if you see two similar pieces of code, deduplicate them.” That reading has caused more damage than copy-paste ever did.

The real meaning, from Hunt and Thomas, is closer to this: every piece of knowledge in the system should have one authoritative representation. The duplication that matters is conceptual, not textual.

Two functions that look alike but encode different ideas should stay separate. Two functions that look different but encode the same idea should be unified. Refactor on the third occurrence, not the second. Until then, the shape of the duplication is still telling you something.

YAGNI

You aren’t gonna need it. The flexibility you build today for the case you imagine tomorrow is almost always wrong. The interface you over-design becomes a constraint when the real requirement arrives. The plugin system gets shipped, never plugged into, and now you maintain it forever.

Ship the simple version. When the second case shows up, design for two. When the third shows up, refactor for the pattern that all three now share. Premature generalization is the same sin as premature optimization, dressed in nicer clothes.

Tests are how you find out you got it right

Here is the link between all of the above and the keyboard.

When you write the test first, you become the first user of your own code. You feel the friction before you commit to it. A class that needs eight mocks to test is telling you something. A method that needs ten lines of setup before you can call it is telling you something. A function whose name you can’t say in one breath is telling you something.

Testable code and well-designed code are not two goals. They are the same goal, viewed from two sides.

  • A class with one reason to change is easy to test because you only need to set up that one reason.
  • A class that depends on interfaces is easy to test because you can pass a fake.
  • A method that tells rather than asks is easy to test because you only check one outcome.
  • A small interface is easy to test because there are fewer ways to call it wrong.

TDD does not magically produce good design. It does something subtler: it punishes bad design fast enough that you fix it while the code is still warm. By the time the implementation is done, the design has been pressure-tested by its first caller.

If you try only one new habit from this post, write the test first for the next class you build. Not for the whole feature. One class. You will learn more about design in an afternoon of that than in a month of reading.

What this looks like in Ruby

Ruby leans hard into messages. An object is whatever responds to the methods you call on it. Duck typing means you almost never write an interface declaration: the contract is the set of methods you actually send.

Here is a small notifier with the principles applied.

class OrderConfirmation
  def initialize(order, notifiers)
    @order = order
    @notifiers = notifiers
  end

  def send
    @notifiers.each { |n| n.notify(@order) }
  end
end

class EmailNotifier
  def initialize(mailer)
    @mailer = mailer
  end

  def notify(order)
    @mailer.deliver(to: order.customer_email, body: render(order))
  end

  private

  def render(order)
    "Thanks for order ##{order.id}"
   end
end
Enter fullscreen mode Exit fullscreen mode

A few things to notice. OrderConfirmation knows nothing about email, SMS, or Slack. It accepts a collection of things that respond to notify. There is no Notifier base class, no interface keyword. The contract lives entirely in the message name. Add a SlackNotifier tomorrow, pass it in, and the existing class doesn’t know the difference.

In tests, this falls out cleanly. You pass a stub that records the call and assert on what was received. No mock library required.

fake = Class.new do
  attr_reader :orders
  def notify(o); (@orders ||= []) << o; end
end.new
OrderConfirmation.new(order, [fake]).send
expect(fake.orders).to eq([order])
Enter fullscreen mode Exit fullscreen mode

The Ruby idiom is to let respond_to? and message-passing carry the design. The interface is implicit, but real.

What this looks like in PHP

Modern PHP comes from the Java side of the family. Types are explicit. Interfaces are declared. The same design principles apply, but the syntax tells you about them out loud.

interface Notifier
{
    public function notify(Order $order): void;
}

final class OrderConfirmation
{
    /** @param Notifier[] $notifiers */
    public function __construct(
        private readonly Order $order,
        private readonly array $notifiers,
    ) {}

    public function send(): void
    {
        foreach ($this->notifiers as $notifier) {
            $notifier->notify($this->order);
        }
    }
}

final class EmailNotifier implements Notifier
{
    public function __construct(private readonly Mailer $mailer) {}

    public function notify(Order $order): void
    {
        $this->mailer->deliver(
            to: $order->customerEmail(),
            body: "Thanks for order #{$order->id()}",
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The shape is the same. One class per reason to change. The high-level OrderConfirmation depends on the Notifier interface, not on EmailNotifier. The mailer is injected. The constructor accepts every dependency rather than reaching for a global.

Testing looks like this in PHPUnit:

$fake = new class implements Notifier {
    public array $received = [];
    public function notify(Order $o): void { $this->received[] = $o; }
};
(new OrderConfirmation($order, [$fake]))->send();
$this->assertSame([$order], $fake->received);
Enter fullscreen mode Exit fullscreen mode

Same idea as Ruby. The difference is that PHP makes the interface a first-class artifact. Ruby trusts the message. Both end up at code you can change without flinching.

Messages versus objects

Worth a short detour on why these two languages, both nominally object-oriented, feel different.

Smalltalk, the language that gave OOP its serious start, was built around messages. Alan Kay later said the big idea was message-passing, not objects. An object is a thing you send messages to. What matters is the conversation between them. Ruby inherits this philosophy directly. obj.foo is sending the foo message; method_missing lets you catch any message at all; the object is defined by what it answers to, not by what it is.

Java, and the broader C++/C# family, is built around classes and types. An object is an instance of a class. The class declares which messages it accepts, and the compiler checks them. Interfaces are how you describe shared behavior across unrelated classes. PHP grew up in this world.

The principles in this post survive both philosophies because they are not about syntax. Single responsibility, dependency inversion, tell-don’t-ask, DRY, YAGNI: none of them care whether your interface is declared with the interface keyword or implied by which messages you send. They care about what depends on what, and how much each piece knows.

When you move between languages, the trick is to translate the principle into the local idiom. In Ruby, “program to an interface” means “depend on a method name, not a class.” In PHP, it means “type-hint the interface, not the implementation.” The intent is the same; the spelling changes.

Try one of these this week

Pick one. Not all of them. The point is to feel the principle in real code, not to memorize a list.

  • Find a class that takes a concrete dependency in its constructor. Change it to accept the interface or duck-typed equivalent. Pass the real thing from the caller. Notice what the test got easier to write.
  • Find an if chain that switches on a type. Replace it with polymorphism. One class per branch. See whether the next case becomes an addition rather than an edit.
  • Find a method that asks an object for data, makes a decision, and writes the result back. Move the decision into the object. Rename the method to name the operation, not the steps.
  • Write one new class with the test first. One class. Notice every place the test felt awkward — that’s a design hint you would have missed.
  • Pick the oldest “we’ll need this someday” abstraction in your codebase. Check whether anyone ever needed it. If not, delete it.

The principles take a few minutes to read and years to absorb. The absorbing happens at the keyboard, not in the article. Go find one small place to try one of them today.

Top comments (0)