Facebook Pixel

SOLID — How They Interlock

The five SOLID principles are not a checklist to run through sequentially. They are five perspectives on the same underlying goal: code that is easy to change without breaking things that should not change. Each principle highlights a different failure mode, and the failures compound: a class that violates SRP is harder to open/close, which makes it painful to substitute, which leads to fat interfaces, which locks in concrete dependencies. Applying one principle often nudges you toward the others.

This article refactors a single messy class in four stages, each stage applying one principle, to make that progression visible. Then it ends with an honest caution — because SOLID applied indiscriminately is its own kind of mess.

The tangled starting point

A checkout system's OrderProcessor has grown into a god class. It validates the order, applies a discount, charges the customer, and sends a confirmation email — all in one place. It directly instantiates the email client and the payment gateway.

1# Before: OrderProcessor does everything and knows everyone
2import smtplib
3import stripe
4
5class OrderProcessor:
6    def process(self, order: "Order", user: "User") -> None:
7        # validate
8        if order.total <= 0:
9            raise ValueError("Empty order")
10        if not user.payment_method:
11            raise ValueError("No payment method on file")
12
13        # apply discount
14        if user.is_premium:
15            order.total *= 0.9
16
17        # charge
18        stripe.Charge.create(amount=int(order.total * 100), currency="usd",
19                             customer=user.stripe_id)
20
21        # email
22        smtp = smtplib.SMTP("mail.internal")
23        smtp.sendmail("orders@shop.com", user.email,
24                      f"Order {order.id} confirmed")

Stage 1 — SRP: split by reason to change

The Single Responsibility Principle says a class should have one reason to change. OrderProcessor has four: validation rules change when business policy changes, discount logic changes when marketing runs a new campaign, payment integration changes when you swap gateways, and email templates change when branding updates. Splitting by reason to change produces four focused collaborators: OrderValidator, DiscountEngine, PaymentGateway, and NotificationService.

Each class is now small enough to read in a few seconds and has a single axis of change.

Stage 2 — OCP: close the discount engine to modification

Once DiscountEngine exists as its own class, the next pain point is obvious: every new promotion requires opening it and adding another if branch. The Open/Closed Principle says classes should be open for extension but closed for modification. The fix is to represent each discount rule as an object that conforms to a DiscountRule interface. Adding a new promotion means adding a new class, not editing an existing one.

Stage 3 — LSP: make substitutes safe

With a PaymentGateway interface introduced during the SRP split, the team adds a TestPaymentGateway that records charges without hitting Stripe. If that test double throws on a charge the real gateway would accept — say it rejects any amount over a hardcoded limit that the production gateway does not enforce — callers can no longer substitute test and production interchangeably. The Liskov Substitution Principle demands that every implementation honor the full contract of the interface — not just the methods the current callers happen to exercise.

Stage 4 — ISP and DIP: slim the interfaces, inject the dependencies

At this point PaymentGateway and NotificationService are interfaces, but they may be too wide. A reporting module that only needs to query past charges should not depend on an interface that also includes refund and preauthorize methods. Segregate to the narrowest interface each client actually needs (ISP). And OrderProcessor should receive its collaborators through constructor injection rather than instantiating them — that is DIP completing the picture.

The result is a composed design where OrderProcessor holds abstractions it did not create.

1# After: each responsibility is isolated; OrderProcessor composes abstractions
2from abc import ABC, abstractmethod
3
4class DiscountRule(ABC):
5    @abstractmethod
6    def apply(self, order: "Order", user: "User") -> None: ...
7
8class PremiumDiscount(DiscountRule):
9    def apply(self, order: "Order", user: "User") -> None:
10        if user.is_premium:
11            order.total *= 0.9
12
13class PaymentGateway(ABC):
14    @abstractmethod
15    def charge(self, amount: float, customer_id: str) -> None: ...
16
17class NotificationService(ABC):
18    @abstractmethod
19    def order_confirmed(self, order: "Order", user: "User") -> None: ...
20
21class OrderValidator:
22    def validate(self, order: "Order", user: "User") -> None:
23        if order.total <= 0:
24            raise ValueError("Empty order")
25        if not user.payment_method:
26            raise ValueError("No payment method on file")
27
28class OrderProcessor:
29    def __init__(
30        self,
31        validator: OrderValidator,
32        rules: list[DiscountRule],
33        gateway: PaymentGateway,
34        notifier: NotificationService,
35    ) -> None:
36        self._validator = validator
37        self._rules = rules
38        self._gateway = gateway
39        self._notifier = notifier
40
41    def process(self, order: "Order", user: "User") -> None:
42        self._validator.validate(order, user)
43        for rule in self._rules:
44            rule.apply(order, user)
45        self._gateway.charge(order.total, user.payment_id)
46        self._notifier.order_confirmed(order, user)

How the principles reinforce each other

SRP created the seams that made OCP possible. You cannot apply OCP to discount logic until it lives in its own class. The interfaces introduced during the split are what LSP and ISP act on — LSP demands the contracts be honored, ISP demands they be narrow. And DIP is only meaningful once those narrow interfaces exist and the high-level class receives them from outside.

The principles form a reinforcing cycle rather than a linear sequence. Violating any one puts pressure on the others. A class that still does too much (SRP violation) will grow a wide interface (ISP violation), which makes it hard to substitute safely (LSP violation), which means callers depend on more concretions than they need (DIP violation).

When to stop

Applying SOLID to the OrderProcessor above was worth it because the class had four real reasons to change and it was already causing pain in tests. The same refactoring applied to a fifty-line utility script that parses a config file is overkill. Premature abstraction is a recognized failure mode: interfaces with one implementation, injected dependencies with no variants, strategy objects wrapping a single if-statement.

The signal to apply SOLID is not "this code could theoretically change." It is "this code is already difficult to test, already has multiple reasons to change, or already requires editing in multiple places for one logical change." Until that friction is present, a straightforward implementation is preferable to a principled one with no concrete benefit.

Invest in Yourself
Your new job is waiting. 83% of people that complete the program get a job offer. Unlock unlimited access to all content and features.
Go Pro