SOLID Principles: A Practical Guide to Software That Can Change
Software design is tested by the second requirement, not the first release. A checkout service that only charges one card processor can look beautifully simple. Six months later it may need bank transfers, regional fraud checks, delayed capture, and an auditable retry policy. The important question is not whether the original code was short. It is whether each new business decision can be introduced without reopening unrelated, already-proven behavior.
SOLID gives us five lenses for examining that question. Robert C. Martin popularized the acronym, while the underlying ideas draw on earlier work about abstraction, modularity, and substitutable types. The principles are not a scorecard, and they do not imply that every concrete class needs an interface. They are prompts for locating the parts of a design where different kinds of change have been tied together. Microsoft's discussion of architectural principles similarly connects separation of concerns, dependency inversion, and explicit dependencies to systems that are easier to maintain.
Working rule: apply a principle only after naming the change or broken promise it is meant to address. “This is not SOLID” is not a design diagnosis; “adding a triangle forces us to edit the area calculator” is.
A running PHP example: shapes and area
Begin with the two deliberately simple data classes from the exercise: a square with public $length and a circle with public $radius.
<?php
class Square
{
public float $length;
public function __construct(float $length)
{
if ($length <= 0) {
throw new InvalidArgumentException('Length must be positive.');
}
$this->length = $length;
}
}
class Circle
{
public float $radius;
public function __construct(float $radius)
{
if ($radius <= 0) {
throw new InvalidArgumentException('Radius must be positive.');
}
$this->radius = $radius;
}
}This first block demonstrates honest, concrete models. Their public properties match the requested starting point, and constructor validation prevents geometrically invalid values. Public mutable properties would be risky in a larger domain because callers could later assign a negative number; PHP readonly properties or private fields with accessors can protect that invariant. For this exercise, keeping the fields public makes the coupling in the calculator easy to see.
A first area calculator often uses runtime type checks:
final class AreaCalculator
{
/** @param array<Square|Circle> $shapes */
public function total(array $shapes): float
{
$sum = 0.0;
foreach ($shapes as $shape) {
if ($shape instanceof Square) {
$sum += $shape->length ** 2;
} elseif ($shape instanceof Circle) {
$sum += M_PI * ($shape->radius ** 2);
} else {
throw new InvalidArgumentException('Unsupported shape.');
}
}
return $sum;
}
}The code calculates correctly for its declared inputs. The problem is its change profile. AreaCalculator knows every concrete shape and every formula. Adding Rectangle or Triangle means modifying a class that already works. Shape data and shape behavior are separated in a way that makes the central conditional grow. Unit tests for the calculator must also be expanded for each concrete type.
The dependency picture is:
flowchart LR
Client --> AreaCalculator
AreaCalculator --> Square
AreaCalculator --> Circle
AreaCalculator -. future edit .-> TriangleThis diagram demonstrates why the calculator is the volatility hotspot: all shape knowledge points into it. The Open/Closed Principle suggests moving the varying calculation behind a stable capability, but the first refactoring step should remain small.
Step one: ask each shape for its area
final class Square
{
public function __construct(public float $length)
{
if ($length <= 0) {
throw new InvalidArgumentException('Length must be positive.');
}
}
public function area(): float
{
return $this->length ** 2;
}
}
final class Circle
{
public function __construct(public float $radius)
{
if ($radius <= 0) {
throw new InvalidArgumentException('Radius must be positive.');
}
}
public function area(): float
{
return M_PI * ($this->radius ** 2);
}
}This block demonstrates information-expert design: each class owns the data needed by its formula and now owns that formula too. Yet AreaCalculator still needs a common type if it is to consume a mixed collection without checking concrete classes. Merely giving unrelated classes methods with the same name is duck typing by convention, not an explicit PHP contract.
Step two: state the abstraction
interface Shape
{
public function area(): float;
}
final class Square implements Shape
{
public function __construct(public float $length)
{
if ($length <= 0) {
throw new InvalidArgumentException('Length must be positive.');
}
}
public function area(): float
{
return $this->length ** 2;
}
}
final class Circle implements Shape
{
public function __construct(public float $radius)
{
if ($radius <= 0) {
throw new InvalidArgumentException('Radius must be positive.');
}
}
public function area(): float
{
return M_PI * ($this->radius ** 2);
}
}The interface demonstrates a narrow behavioral abstraction: anything accepted as a Shape can produce a finite area in square units. Production code should document units and numeric expectations because the signature alone cannot say whether a radius is centimeters or meters. Notice that the abstraction contains only behavior shared by the consumer. It does not force Circle to invent length(), or Square to pretend it has a radius.
Step three: make the calculator closed to shape formulas
final class AreaCalculator
{
/** @param list<Shape> $shapes */
public function total(array $shapes): float
{
return array_reduce(
$shapes,
static fn (float $sum, Shape $shape): float => $sum + $shape->area(),
0.0
);
}
}
final class Rectangle implements Shape
{
public function __construct(
public float $width,
public float $height
) {
if ($width <= 0 || $height <= 0) {
throw new InvalidArgumentException('Dimensions must be positive.');
}
}
public function area(): float
{
return $this->width * $this->height;
}
}This block demonstrates extension without editing AreaCalculator. Rectangle can be added because the calculator depends on Shape, not on a list of concrete classes. It also illustrates Dependency Inversion: the high-level operation “total the areas” and low-level formulas both meet at a small abstraction.
The refactoring changes the dependency direction:
Before: AreaCalculator -> Square, Circle, every future shape After: AreaCalculator -> Shape <- Square, Circle, Rectangle
That ASCII view demonstrates an important nuance: the interface is useful because it separates a stable aggregation policy from varying formulas. Introducing it before there was a consumer or variation would have been speculative.
Step four: prove substitutability
final class AreaCalculatorTest extends PHPUnitFrameworkTestCase
{
public function testItTotalsDifferentShapes(): void
{
$calculator = new AreaCalculator();
$total = $calculator->total([
new Square(2.0),
new Circle(1.0),
new Rectangle(2.0, 3.0),
]);
self::assertEqualsWithDelta(10.0 + M_PI, $total, 0.000001);
}
public function testEveryShapeProducesANonNegativeFiniteArea(): void
{
$shapes = [new Square(1.0), new Circle(1.0), new Rectangle(1.0, 2.0)];
foreach ($shapes as $shape) {
self::assertGreaterThanOrEqual(0.0, $shape->area());
self::assertTrue(is_finite($shape->area()));
}
}
}The first test demonstrates the application behavior. The second is a miniature contract test: every implementation preserves properties a Shape consumer relies on. Floating-point values are compared with a tolerance rather than exact equality. For financial or engineering work requiring exact units and rounding policy, a richer value object or decimal library would be appropriate.
| Design stage | New shape requires | Main risk | Best next action |
|---|---|---|---|
| Concrete type checks | Edit the calculator | Missed branch or regression | Extract common behavior |
| Same-named methods only | Caller convention | No enforced promise | Add a client-owned interface |
| Shape interface | New implementation | Invalid subtype behavior | Run shared contract tests |
| Many shape capabilities | Ever-growing interface | Unsupported methods | Split by client need |
The table demonstrates that SOLID is a sequence of design responses, not a destination. Each improvement exposes a different risk. An interface solves the calculator's concrete dependency but creates a responsibility to keep all implementations behaviorally valid.
Start with a change map, not an acronym
Imagine an order-confirmation component that validates an order, calculates tax, writes a row, renders HTML, and sends email. It has one public method, so a superficial reading might call it a single responsibility. In practice it changes when tax rules change, when the database schema changes, when designers revise email markup, or when the mail vendor changes. Those are separate reasons to edit and separate groups of stakeholders. The Single Responsibility Principle is about that axis of change, not method count.
Before extracting anything, write a change map. List the business policies, data ownership, presentation formats, integrations, and operational policies in the module. Mark which changes usually arrive together. Tax calculation and tax jurisdiction data may belong together. Email rendering and payment persistence probably do not. This exercise keeps refactoring grounded in observed pressure. A cohesive sixty-line module is often better than twelve tiny classes connected by indirection.
A practical split might leave an application service coordinating these capabilities:
confirmOrder(command): order = orderPolicy.validate(command) totals = taxPolicy.price(order) receipt = orderRepository.save(order, totals) notifier.sendConfirmation(receipt) return receipt.id
The coordinator owns the use-case sequence. Each collaborator owns a distinct policy or boundary. This is not “one class per verb.” It is a deliberate alignment between ownership and reasons to change.
Open for the variations you actually have
The Open/Closed Principle says software entities should be open for extension but closed for modification. Taken literally, that is impossible: requirements sometimes require changing core policy. The useful interpretation is narrower. Stable, high-value behavior should not need repeated edits whenever a known dimension varies.
Suppose discounts vary by campaign. A growing switch statement inside checkout makes every new campaign a change to the same risky module. A discount policy contract can isolate that variation:
interface DiscountPolicy { quote(order, context): Discount }
The checkout flow remains stable while campaign policies are added and selected in a composition root. But do not build a plug-in system merely because a second implementation is imaginable. If pricing has one rule and no evidence of variation, direct code is clearer. Extension points carry costs: more concepts, more configuration, more combinations to test, and the possibility that the abstraction predicts the wrong future.
Use production history and the roadmap as evidence. Extract a boundary after repeated changes, before an imminent known integration, or when a volatile dependency would otherwise contaminate important business logic. Record the variation the extension point supports so it does not become a miscellaneous hook.
Substitutability is observable behavior
The Liskov Substitution Principle is often reduced to “a subclass should work anywhere its parent works.” Barbara Liskov and Jeannette Wing's paper on behavioral subtyping is more precise: subtype reasoning depends on preserved properties, not just matching method signatures. Callers rely on preconditions, postconditions, invariants, side effects, and failure semantics.
Consider a Storage abstraction whose save operation promises durable success or an explicit error. An in-memory implementation that silently evicts records under pressure is not substitutable, even if its method has the same type. A read-only subclass that throws from inherited write methods is another warning: the supposed subtype cannot honor the parent's capability.
Write contracts in observable terms. Can an implementation require stricter input? Can it return a weaker result? Does it change ordering, atomicity, idempotency, or timing guarantees? Does a fake used in tests accept invalid transitions that the database adapter rejects? Shared contract tests are valuable:
for each repository implementation: saving a new order makes it retrievable duplicate idempotency keys do not create two orders version conflicts return Conflict, not NotFound failed writes do not expose partial state
These tests turn substitution from a diagram claim into executable evidence. They are especially important around caches, queues, repositories, and vendor adapters, where superficially similar implementations often have materially different semantics.
Interfaces belong to clients
The Interface Segregation Principle warns against forcing clients to depend on operations they do not use. A broad CommercePlatform interface with catalog, checkout, refunds, reporting, and administration methods creates accidental coupling. A reporting job should not need a test double that implements checkout. A refund worker should not recompile because catalog browsing changed.
Shape contracts around client capabilities: OrderReader, PaymentAuthorizer, RefundIssuer. This does not require a separate source file for each three-method interface. Languages with structural typing can define the required shape near the consumer. The design goal is a narrow dependency surface.
Segregation can go too far. If callers routinely need the same cluster of operations to maintain an invariant, splitting them may hide useful cohesion. A UnitOfWork that atomically saves an order and outbox record may be a stronger contract than two independent writers. Ask whether methods change together, share transaction semantics, and are consumed together. Segregate unrelated capabilities, not every operation.
For a deeper treatment of boundary design, see Interfaces as Software Contracts. It examines errors, compatibility, and contract suites beyond class syntax.
Point dependencies toward policy
Dependency Inversion says high-level policy should not depend directly on low-level details; both should depend on abstractions, and abstractions should not be owned by details. In the checkout example, business code needs the capability to authorize a payment. It should not speak in a vendor SDK's request objects or construct an HTTP client. Define the capability in business terms and adapt the vendor at the edge.
interface PaymentAuthorizer { authorize(amount, paymentMethod, idempotencyKey): AuthorizationResult }
class VendorPaymentAdapter implements PaymentAuthorizer { // translates domain values to vendor request and response shapes }
The direction matters. Copying every method from a vendor SDK into IPaymentVendor only adds a mockable wrapper; it does not protect the policy from vendor concepts. A business-facing abstraction should express approved, declined, retryable, and indeterminate outcomes in terms the use case can act on. Construction belongs in a visible composition root. Dependency Injection Without Framework Magic shows how plain constructors and factories can do this without a container.
Refactor a live system safely
Do not announce a SOLID rewrite. Pick one costly change path. Characterize current behavior with tests at a stable boundary, including failures and side effects. Identify one responsibility or volatile dependency, define its contract from the caller's needs, and move behavior behind it in small commits. Keep old and new paths comparable where the risk is high.
For payment extraction, first capture cases for approval, decline, timeout, duplicate submission, and malformed vendor responses. Add correlation IDs and metrics before changing flow. Introduce an adapter that initially delegates to existing code. Move translation logic, then switch construction at the application edge. Only remove the old path after production telemetry shows equivalent outcomes.
Review the result using change scenarios: add a provider, alter retry policy, or support authorization without capture. Count the modules touched and the contracts changed. The objective is not maximum abstraction. It is a smaller, more predictable blast radius.
Testing and operating a SOLID design
Unit tests should concentrate on policy decisions, but boundary tests must verify real adapters. A fake clock makes expiration logic deterministic; it does not prove timestamps survive database conversion. Contract tests should run against the in-memory repository and the actual database implementation. Integration tests should exercise vendor sandboxes or a protocol-level stub that can reproduce malformed responses, throttling, and timeouts.
Observability must follow responsibilities. Emit business outcomes at the application-service level and technical diagnostics inside adapters. Measure authorization outcomes by provider, but avoid putting vendor-specific branching back into core policy merely to label a metric. Trace context should cross interfaces explicitly or through carefully controlled infrastructure context.
Watch abstraction health in code review. A constructor with fourteen dependencies suggests the module still has too many responsibilities. Frequent interface changes may mean the boundary is in the wrong place. Downcasts and type checks often reveal broken substitutability. Methods implemented as “not supported” expose an oversized parent contract. These are diagnostic signals, not automatic violations.
Where SOLID fails teams
The common failure is ceremonial compliance: IService for every Service, one implementation per interface, and factories that only call constructors. This creates navigation cost without isolating variation. Another failure is treating principles independently. Extracting a dozen responsibilities but injecting them all into one coordinator may simply relocate complexity. A third is preserving a bad abstraction because “open/closed” supposedly forbids modification. When the model is wrong, change it deliberately and migrate callers.
SOLID also does not answer service boundaries, consistency models, data ownership, or distributed failure. Those need architectural reasoning. A good class design inside a poorly chosen service boundary remains difficult to operate. The Clean Architecture Boundaries article connects dependency direction to larger application structure, while /resources provides additional implementation references.
A practical definition of success
SOLID succeeds when a meaningful change becomes local, the contract names business intent, and tests can prove implementations preserve required behavior. It fails when the acronym becomes the design goal. Start with forces: who requests changes, what varies, what must remain true, and which details are volatile. Then use the five principles to challenge the coupling you find.
The best design may still contain concrete classes, direct function calls, and a carefully placed conditional. That is not a compromise. The point is to spend abstraction where it protects important policy and recurring variation. Software becomes changeable not by accumulating interfaces, but by making reasons to change, behavioral promises, client needs, and dependency direction explicit.
Before merging a SOLID-oriented refactor, use this short review:
- [ ] Can the team name the concrete change pressure?
- [ ] Does each extracted module own a coherent policy or boundary?
- [ ] Do subtypes preserve success, failure, and side-effect promises?
- [ ] Are interfaces shaped by consumers rather than vendors?
- [ ] Does business policy avoid constructing infrastructure?
- [ ] Are real adapters covered by integration or contract tests?
- [ ] Is the result easier to trace, not merely more abstract?
This checklist demonstrates the balance the acronym needs. It asks for evidence of lower coupling while protecting readability and operational truth.
One final quantitative check is useful, even though it is not a design score: select two realistic change scenarios and list the modules, contracts, and test suites each would touch before and after the refactor. A good boundary usually reduces unrelated edits while adding a small, understandable contract cost. If the refactor merely moves the same conditional across more files, reverse it. If it lets a new shape, provider, or policy arrive through one implementation and one shared contract suite, it has created practical room for change. Review the map again after several releases, because yesterday's volatile integration can become stable while a previously simple business rule begins changing weekly.



