SOLID as a system — how the 5 principles reinforce each other
Concept
SOLID is not five independent rules — it is a system of mutually reinforcing principles. Once you see how they connect, applying one of them naturally guides you toward the others. They are different facets of the same underlying goal: writing code that is easy to change, test, and understand.
The connections between the five principles:
SRP enables OCP: A class with one responsibility is easier to close for modification because its behavior is well-defined and bounded. When a class does too much, you cannot close it because every new feature type requires you to edit it.
OCP requires DIP: You cannot be open for extension without abstractions. The Strategy pattern (OCP) only works if you depend on an interface (DIP). OCP says "add new code"; DIP says "depend on abstractions"; together they say "add new implementations of abstractions."
LSP enables OCP: For OCP to work — for polymorphic substitution to actually work — every concrete implementation must be a valid substitute for the abstraction. If a new GatewayImplementation breaks the PaymentGateway contract, the extension does not work correctly. LSP is the precondition for OCP.
ISP supports LSP: Fat interfaces make LSP violations more likely because they force implementors to handle more than they should, creating "lie" implementations that throw exceptions or do nothing. Segregated interfaces make it easier for each implementation to fulfill its contract completely.
DIP ties everything together: All four other principles ultimately flow through DIP. Dependencies on abstractions enable single responsibilities to be cleanly separated, extensions to be added without modification, valid substitutions to be made, and callers to depend only on what they need.
| Principle | What it prevents | How it connects |
|---|---|---|
| SRP | Classes that change for multiple reasons | Focused classes are easier to extend (OCP) |
| OCP | Editing working code to add new features | Extension requires abstractions (DIP) |
| LSP | Subtypes that break callers silently | Valid substitutions enable OCP to work |
| ISP | Callers depending on methods they don't use | Focused interfaces prevent forced LSP violations |
| DIP | High-level logic coupled to infrastructure | Abstractions make SRP, OCP, LSP, ISP all possible |
Code Example
<?php
declare(strict_types=1);
/**
* A complete example showing all five SOLID principles working together.
*
* Domain: An order notification system that notifies customers after purchase
* through different channels.
*/
// DIP: Define the abstraction — the domain owns this interface
// ISP: Interface has only what callers need (one method)
interface NotificationChannel
{
public function send(User $user, Notification $notification): void;
}
// OCP + LSP: New channels are new classes; all fulfill the same contract
final class EmailChannel implements NotificationChannel
{
public function send(User $user, Notification $notification): void
{
Mail::to($user->email)->send(new NotificationMail($notification));
}
}
final class SmsChannel implements NotificationChannel
{
public function __construct(private readonly TwilioClient $twilio) {}
public function send(User $user, Notification $notification): void
{
$this->twilio->messages->create($user->phone, [
'from' => config('services.twilio.from'),
'body' => $notification->body,
]);
}
}
// Adding a push notification channel — zero changes to anything above
final class PushChannel implements NotificationChannel
{
public function __construct(private readonly FirebaseClient $firebase) {}
public function send(User $user, Notification $notification): void
{
$this->firebase->send([
'token' => $user->push_token,
'title' => $notification->title,
'body' => $notification->body,
]);
}
}
// SRP: NotificationDispatcher has ONE job — dispatching to the right channels
// DIP: Depends on the NotificationChannel abstraction, not concrete classes
final class NotificationDispatcher
{
/** @param NotificationChannel[] $channels */
public function __construct(private readonly array $channels) {}
public function dispatch(User $user, Notification $notification): void
{
foreach ($this->channels as $channel) {
$channel->send($user, $notification);
}
}
}
// SRP: OrderNotificationService has ONE job — orchestrating post-order notifications
// All its dependencies are abstractions (DIP)
final class OrderNotificationService
{
public function __construct(
private readonly NotificationDispatcher $dispatcher,
private readonly UserRepository $users, // interface, not Eloquent
) {}
public function notifyAfterPurchase(int $orderId): void
{
$order = Order::findOrFail($orderId);
$user = $this->users->findById($order->user_id);
$notification = new Notification(
title: 'Order confirmed',
body: "Your order #{$orderId} has been placed successfully.",
);
$this->dispatcher->dispatch($user, $notification);
}
}
// In the service provider (DIP wiring):
$this->app->bind(NotificationDispatcher::class, function (Application $app): NotificationDispatcher {
return new NotificationDispatcher([
$app->make(EmailChannel::class),
$app->make(SmsChannel::class),
// Add PushChannel — no code changes needed in any other class
]);
});Interview Q&A
Q: If you could only apply one SOLID principle, which has the highest leverage?
Dependency Inversion. DIP forces you to introduce abstractions at the boundaries between volatile dependencies (databases, APIs, email providers) and your domain logic. Once you have those abstractions, you naturally get: SRP (each class serves one role against one abstraction), OCP (new implementations extend the abstraction without modifying callers), LSP (the abstraction defines the contract that all implementations must fulfill), and ISP (the abstraction naturally expresses only what the caller needs). DIP is the architectural principle that enables the other four to take hold. That said, SRP is often the easiest entry point because reducing class responsibilities is immediately tangible and improves testability on its own.
Q: How do you avoid SOLID becoming an over-engineering exercise?
Apply SOLID at natural seams — boundaries between layers, between domain and infrastructure, between concerns with different rates of change. Do not apply it to every piece of code. A utility function that parses a date does not need an interface. A one-method action class does not need an abstract base class. SOLID principles pay dividends in proportion to how much a piece of code changes and how many different contexts it is used in. The heuristic: when you find yourself needing to change a tested, working class to accommodate a new variant — that is when to apply OCP/DIP. When a class is accumulating unrelated functionality — that is when to apply SRP. React to real change pressure rather than speculating.
Q: How would you explain SOLID to a junior developer in one sentence each?
SRP: Each class should change for only one reason — one boss. OCP: You should be able to add new behavior by writing new code, not editing old code. LSP: A subtype must always work correctly wherever its parent type is expected — no surprises. ISP: Don't force a class to implement methods it doesn't use — keep interfaces focused. DIP: Business logic should depend on abstractions it defines, not on the concrete database/email/API libraries underneath it.