SOLID Principles in Real Code: Examples and Anti-Patterns

Philip Rehberger Aug 26, 2026 6 min read

Apply SOLID to actual code instead of toy examples. Includes the cases where each principle stops helping.

The SOLID principles get taught as five rules to memorize. In practice, they are five lenses for looking at code — useful when applied with judgment, harmful when applied dogmatically. This post is examples from real code, with the cases where each principle stops helping.

S — Single Responsibility

The textbook definition: a class should have one reason to change. The trap: every developer interprets "responsibility" differently, and a class with too many narrowly-defined "responsibilities" ends up with one method per file.

Useful application:

// Before — one class, multiple reasons to change
final class Invoice
{
    public function calculateTotal(): int { /* ... */ }
    public function renderHtml(): string { /* ... */ }
    public function sendByEmail(): void { /* ... */ }
    public function persistToDatabase(): void { /* ... */ }
}

Each of those methods has a different reason to change. The renderer changes when the template changes; the persister changes when the schema changes; the mailer changes when the provider changes. Splitting them produces classes that are easier to test and easier to reason about.

final class Invoice           { /* data + calculations */ }
final class InvoiceRenderer   { /* HTML rendering */ }
final class InvoiceMailer     { /* sending */ }
final class InvoiceRepository { /* persistence */ }

Anti-pattern: splitting Invoice into InvoiceTotalCalculator, InvoiceTaxCalculator, and InvoiceDiscountCalculator because each computes one thing. That is not "single responsibility" — that is procedural code wearing class clothes.

O — Open/Closed

Open for extension, closed for modification. In practice: design code so that new behavior can be added by writing new code, not by editing the existing code.

Useful application: payment methods.

interface PaymentMethod
{
    public function charge(Money $amount): ChargeResult;
}

final class StripeMethod implements PaymentMethod   { /* ... */ }
final class PaypalMethod implements PaymentMethod   { /* ... */ }
final class AchMethod implements PaymentMethod      { /* ... */ }

final class CheckoutService
{
    public function pay(PaymentMethod $method, Money $amount): ChargeResult
    {
        return $method->charge($amount);
    }
}

Adding a new payment method means a new class, not editing CheckoutService. The service is closed for modification.

Anti-pattern: applying open/closed to code that has no extension points and never will. Premature interfaces for things that have one implementation, ever, are not flexibility — they are friction.

L — Liskov Substitution

A subclass should be substitutable for its parent without surprising the caller. Most LSP violations look like an override that throws an exception or returns a fundamentally different shape.

Classic violation:

class Bird
{
    public function fly(): void { /* ... */ }
}

class Ostrich extends Bird
{
    public function fly(): void
    {
        throw new RuntimeException('Ostriches cannot fly');
    }
}

Code that takes a Bird and calls fly() will crash on an Ostrich. The inheritance is wrong; Bird is not a useful supertype if not all birds fly.

The lesson generalizes beyond inheritance. Any time you have an interface, every implementation has to honor the contract — not just satisfy the type signature, but behave the way callers expect.

Anti-pattern: avoiding inheritance entirely because of LSP fear. Inheritance is still useful when the subclass is a genuine specialization that preserves the parent's contract.

I — Interface Segregation

Clients should not be forced to depend on methods they do not use. Practically: prefer many small, focused interfaces over fewer large ones.

Useful application:

// Too broad — implementations depend on methods they don't need
interface Repository
{
    public function find(int $id);
    public function create(array $attrs);
    public function update(int $id, array $attrs);
    public function delete(int $id);
    public function search(string $q);
    public function export(): string;
}

// Segregated — pick what you need
interface FindsRecords  { public function find(int $id); }
interface SearchesRecords { public function search(string $q); }
interface MutatesRecords { /* create, update, delete */ }

A read-only consumer only needs FindsRecords. A bulk-import job only needs MutatesRecords. Each implementation declares exactly what it provides.

Anti-pattern: one-method interfaces for every operation. Coherent groups of methods belong together; splitting too aggressively makes the type system noisier without giving real benefit.

D — Dependency Inversion

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Useful application:

// Wrong direction — high-level service depends on low-level Mailgun
final class OnboardingService
{
    public function welcome(User $user): void
    {
        (new MailgunClient(env('MAILGUN_KEY')))
            ->send($user->email, 'Welcome');
    }
}

// Inverted — both depend on the abstraction
interface Mailer
{
    public function send(string $to, string $subject): void;
}

final class OnboardingService
{
    public function __construct(private Mailer $mailer) {}

    public function welcome(User $user): void
    {
        $this->mailer->send($user->email, 'Welcome');
    }
}

final class MailgunMailer implements Mailer { /* ... */ }

Now OnboardingService does not know Mailgun exists. Swapping providers does not require changing it.

Anti-pattern: inverting dependencies that are stable. If Money::add() is in your code forever and will never be replaced, abstracting it behind MoneyOperator is busywork.

The Principles Together

The principles overlap and interact. A class that follows S often naturally follows O. A codebase that follows D usually has reasonable I. They are not five independent rules — they are different views of the same underlying concern: keep code easy to change.

The principles also fail together. SOLID applied dogmatically produces:

  • A flood of one-method classes (S applied too literally)
  • Interfaces for everything (O and D applied speculatively)
  • Code so segregated that finding the actual logic takes ten minutes (I applied without judgment)
  • A type hierarchy nobody can navigate (L applied through inheritance instead of composition)

The codebases that suffer most from "SOLID rot" are ones where every rule was followed as gospel. The codebases that benefit most are ones where engineers ask "is this change easier to make because of these principles, or harder?" and adjust accordingly.

When the Principles Stop Helping

Recognize the warning signs:

  • The class names are getting absurd (InvoicePaymentProcessorFactoryBuilder)
  • Tests have more setup than logic
  • Tracing what happens for a single user action requires opening twenty files
  • Engineers describe the codebase as "very SOLID" with no positive affect

When you see these, the issue is usually not that more SOLID is needed. It is that the principles have been applied past the point where they pay back.

The Practical Rule

SOLID is a tool for managing change. Apply it when you are likely to change the code, in the direction the principle is helping. Skip it for code that is stable and will not benefit from the abstraction. A code review question that catches most over-application: "what change does this abstraction make easier, and is that change actually likely?"

If the answer is "future flexibility" with no concrete scenario, the abstraction is probably costing more than it gives back.


Working through a codebase where the SOLID dogma has produced more problems than it solved? We help teams refactor for clarity over compliance with principles. scopeforged.com

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.