· 8 min read A2

SOLID Principles with Real Examples

SOLID is one of the most common topics in technical interviews, but many explanations are too abstract. Here are the five principles with real backend examples and honest advice about when to use them.

SOLID is a group of five design principles. Robert C. Martin ("Uncle Bob") made them popular in the early 2000s. Today they are still a very common question in job interviews for backend developers.

The problem is that many explanations use examples with animals, shapes or cars. In real life, I never write a Duck class. I write payment services, API clients and repositories. So in this post I explain each principle with examples from real backend work.

The five principles are:

  • S – Single Responsibility Principle
  • O – Open/Closed Principle
  • L – Liskov Substitution Principle
  • I – Interface Segregation Principle
  • D – Dependency Inversion Principle

Let's see them one by one.

S – Single Responsibility Principle

A class should have only one reason to change.

"One reason to change" is the important part. It doesn't mean "one method". It means one actor or one topic that can ask you for changes.

The bad example

final class InvoiceService
{
    public function create(array $data): void
    {
        // 1. validate the data
        // 2. calculate taxes
        // 3. save in the database
        // 4. generate a PDF
        // 5. send an email to the client
    }
}

Who can ask for changes in this class?

  • The finance team (tax rules).
  • The DBA (database changes).
  • The design team (PDF layout).
  • The marketing team (email text).

Four different reasons to change. Every change is a risk for the other parts.

The better example

final class CreateInvoice
{
    public function __construct(
        private TaxCalculator $taxes,
        private InvoiceRepository $invoices,
        private InvoicePdfGenerator $pdf,
        private InvoiceMailer $mailer,
    ) {}

    public function __invoke(CreateInvoiceCommand $command): void
    {
        $invoice = Invoice::create($command, $this->taxes);
        $this->invoices->save($invoice);
        $this->mailer->send($invoice, $this->pdf->generate($invoice));
    }
}

Now each class has one job. The use case only coordinates. If the PDF layout changes, you only touch InvoicePdfGenerator.

Tip: if you need the word "and" to describe your class ("it validates AND saves AND sends emails"), it probably has too many responsibilities.

O – Open/Closed Principle

Software should be open for extension but closed for modification.

In simple words: when you add a new feature, try to add new code instead of changing old code that already works.

The bad example

final class PaymentProcessor
{
    public function pay(string $method, Money $amount): void
    {
        if ($method === 'card') {
            // call Stripe
        } elseif ($method === 'bizum') {
            // call Bizum
        } elseif ($method === 'paypal') {
            // call PayPal
        }
    }
}

Every new payment method means changing this class. Every change can break the other methods.

The better example

interface PaymentGateway
{
    public function supports(string $method): bool;
    public function pay(Money $amount): PaymentResult;
}

final class PaymentProcessor
{
    /** @param iterable<PaymentGateway> $gateways */
    public function __construct(private iterable $gateways) {}

    public function pay(string $method, Money $amount): PaymentResult
    {
        foreach ($this->gateways as $gateway) {
            if ($gateway->supports($method)) {
                return $gateway->pay($amount);
            }
        }
        throw new UnsupportedPaymentMethod($method);
    }
}

To add Apple Pay, you create a new ApplePayGateway class. PaymentProcessor doesn't change. In Symfony, you can register all gateways automatically with a tagged iterator.

This is the Strategy pattern, and it is the most common way to apply Open/Closed.

L – Liskov Substitution Principle

If B is a subtype of A, you should be able to use B anywhere you use A, and the program still works correctly.

This principle is about keeping promises. If an interface promises something, every implementation must keep that promise.

The bad example

interface FileStorage
{
    public function save(string $path, string $content): void;
}

final class ReadOnlyS3Storage implements FileStorage
{
    public function save(string $path, string $content): void
    {
        throw new \LogicException('This storage is read-only');
    }
}

The interface says "you can save files". This implementation says "no, you can't". Code that receives a FileStorage will fail in surprising ways.

Other common violations

  • An implementation returns null when the interface promises an object.
  • An implementation throws new exception types that callers don't expect.
  • An implementation requires extra conditions ("only works if the file is smaller than 1 MB") that the interface doesn't mention.

The fix

If some storages are read-only, model that with separate interfaces: FileReader and FileWriter. That brings us to the next principle.

Practical test: can you run the same test suite against every implementation of an interface? If yes, you probably respect Liskov. I use this idea a lot with repositories: the same contract tests run against the in-memory version and the Doctrine version.

I – Interface Segregation Principle

Clients should not be forced to depend on methods they don't use.

Big interfaces create problems. Every class that implements them must write all methods, even the ones that make no sense.

The bad example

interface UserRepository
{
    public function find(UserId $id): ?User;
    public function save(User $user): void;
    public function delete(UserId $id): void;
    public function search(UserCriteria $criteria): array;
    public function exportToCsv(): string;
    public function countActiveByCountry(): array;
}

A use case that only needs to find a user now depends on CSV exports and statistics. A fake for tests must implement six methods.

The better example

interface UserFinder
{
    public function find(UserId $id): ?User;
}

interface UserWriter
{
    public function save(User $user): void;
}

interface UserReports
{
    public function countActiveByCountry(): array;
}

One class (for example DoctrineUserRepository) can implement all three. But each use case only asks for what it needs. This also makes the code easier to read: when you see the constructor, you know exactly what the class does.

This idea fits very well with CQRS, where you separate reading (queries) from writing (commands).

D – Dependency Inversion Principle

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

This is my favourite principle, and it is the base of hexagonal architecture.

"High-level" means business logic. "Low-level" means technical details: database, HTTP client, file system, LLM SDK.

The bad example (Python this time)

import anthropic

class SummarizeArticle:
    def __init__(self):
        self.client = anthropic.Anthropic()

    def execute(self, text: str) -> str:
        response = self.client.messages.create(
            model="claude-sonnet-5-5",
            max_tokens=500,
            messages=[{"role": "user", "content": f"Summarize: {text}"}],
        )
        return response.content[0].text

The business logic ("summarize an article") is tied to one specific SDK. To test it, you need a real API key or complex mocks.

The better example

from typing import Protocol

class TextSummarizer(Protocol):
    def summarize(self, text: str) -> str: ...

class SummarizeArticle:
    def __init__(self, summarizer: TextSummarizer):
        self.summarizer = summarizer

    def execute(self, text: str) -> str:
        if len(text) < 200:
            return text
        return self.summarizer.summarize(text)

Now the use case depends on an abstraction (TextSummarizer). The Anthropic adapter, the OpenAI adapter or a fake adapter for tests all implement it. The dependency is "inverted": the technical detail depends on the business interface, not the other way.

If you want to see how this looks in a full application, read Hexagonal Architecture Explained Simply.

When NOT to use SOLID

This part is important, and many tutorials forget it. SOLID principles are guides, not laws.

  • Don't create interfaces with only one implementation "just in case". If you never need a second implementation and you don't need fakes for tests, an interface is extra noise.
  • Don't split classes until they are too small to understand. Ten classes of five lines each can be harder to follow than one class of fifty lines.
  • Scripts and prototypes don't need SOLID. A one-time migration script can be a simple file.
  • Think about the cost of change. Apply SOLID where change is likely: payment methods, external providers, business rules. Don't apply it to code that will never change.

A good question to ask: "What will probably change here in the next year?" Put your abstractions around those points.

How to answer SOLID in an interview

When someone asks you about SOLID in an interview, don't only recite the definitions. Interviewers hear the definitions all day. Give a short definition and then a real example from your work. Something like:

"For me, the most useful one is Dependency Inversion. In my last project we integrated three payment providers. The use cases depended on a PaymentGateway interface, so adding a new provider was one new class and zero changes in the business logic. It also let us test everything with a fake gateway."

This answer shows that you understand the principle and that you used it for a real reason.

Summary

Principle Simple idea Real example
S One reason to change Split invoice creation, PDF and email
O Add code, don't modify New payment gateway = new class
L Keep the interface promise Same contract tests for all repositories
I Small, focused interfaces UserFinder vs a huge UserRepository
D Depend on abstractions Use case depends on TextSummarizer, not the SDK

SOLID helps you write code that is easy to change and easy to test. Use it where change is real, and keep things simple everywhere else.

Share: X LinkedIn

Related posts