“This violates the single-responsibility principle” is not an explanation.
It may be the conclusion of one. The explanation starts earlier:
Lookup policy, selection policy, and construction policy change for different
reasons. One class owns all three. A lookup change therefore makes selection
tests and construction code part of the review surface.Now “single responsibility” gives that recurring pressure a short name. The name helps another engineer retrieve the argument. It does not replace it.
The best design principles are compressed failure histories. They remember what repeatedly became difficult, why it became difficult, and which response usually reduced the pressure. Used without that history, they become rules which can justify almost any refactor.
Start with the pressure
The previous twelve articles in this series did not begin with acronyms. They began with observable mismatches:
- classes with the same generic name answered different caller questions;
- partial values could construct a state no business operation supported;
- two booleans encoded one invalid combination;
- a scalar hid validation and comparison rules;
- a long procedure moved into a service without changing its reasons to change;
- local construction acquired a remote dependency from global machinery;
- a lifecycle hook published work before the surrounding transaction committed;
- a process-wide clock made one test change another test’s result;
- an in-memory factory operation wrote durable related rows;
- lifecycle commands did not fully distinguish change, repetition, and rejection;
- a concrete client offered a caller nine operations outside its declared role; and
- a transport replacement showed which seams held and which workflows had bypassed them.
Each case has its own mechanism. Several share a change pressure.
That shared pressure is where a principle becomes useful:
observed mismatch
-> recurring cost or risk
-> bounded response
-> short principle name
-> prediction for the next changeSkipping directly to the name loses the causal chain.
Single responsibility compresses change ownership
A 1,900-line procedure is not wrong because it is long. A fourteen-line class is not cohesive because it is short.
The useful question is which changes it owns.
The retained design work separated lookup, selection, operation construction, preflight, persistence, remote booking, response construction, and post-response effects only after those phases showed independent pressure. A small orchestrator remained because ordering the phases was itself one responsibility.
“One reason to change” compresses that history. It predicts:
If selection policy changes, registry lookup and operation assembly should not
need corresponding edits unless their contracts also change.That prediction can be checked in a diff.
The principle does not say:
Every class must be small.
Every method deserves a class.
Coordinators are design failures.If several steps enforce one atomic invariant and always change together, splitting them may scatter the real responsibility.
Dependency inversion compresses source knowledge
One factory call looked local but triggered a remote lookup through framework state. Its caller could not see the dependency, choose its failure behavior, or install a substitute before the work began.
The pressure was not “concrete classes are bad.” It was that high-level use-case code acquired an operational capability through ambient machinery.
“Depend on an abstraction” is the compressed response. A better prediction is:
If the remote transport or provider changes, the use case should keep its
business input, result, and failure decisions.The next-change article tested that prediction. Some application-facing client roles absorbed a transport replacement. Controllers which constructed a vendor connection directly had to change.
That result also supplies the limit. An interface can preserve arrays, transport responses, and provider meanings. Dependency inversion changes which source code knows the implementation. It does not guarantee a good semantic boundary.
Interface segregation compresses caller differences
A concrete payment client exposed fourteen operations. One driver relied on five through its declared role. Nine concrete operations disappeared during a replacement without changing that caller.
“Clients should not depend on methods they do not use” is a concise description of the pressure. The evidence was not the number five. It was that different callers needed different groups of behavior and one caller survived the removal of the rest.
The prediction becomes:
If an unrelated provider capability changes or disappears, callers which do
not own that capability should remain unchanged.This principle does not require an interface for every class. A one-caller concrete dependency may be clearer. Nor does an interface remove public methods from the runtime object. When reachability itself matters, a wrapper must hold the broader capability and expose only the smaller one.
The principle remembers a collaboration problem, not a preferred file count.
Immutability compresses ownership of change
A frozen process clock made tests deterministic. Without guaranteed cleanup, one test could leave the next test in a different time.
The deeper pressure was shared mutable ownership. Any code could change the clock; unrelated code observed the result; lifetime extended beyond the local calculation.
Passing one reference instant into a decision removed that ownership question:
$result = $policy->evaluate($input, $referenceInstant);“Prefer immutable values” compresses the response. Its prediction is:
If another caller evaluates the same input concurrently or later, this
calculation's reference values cannot be changed underneath it.That does not make all mutation wrong. A balance reservation deliberately moves from pending to a terminal state. The important work is to give that mutation one transactional owner and return an outcome which describes it.
Immutability is useful where shared aliasing makes local reasoning unreliable. Explicit state transitions are useful where change is the domain behavior. Both are responses to ownership, not opposing religions.
Principles overlap because failures overlap
Consider a controller which constructs a broad vendor client, calls three remote resources, mutates local state, and catches package exceptions.
Several diagnoses fit:
- single responsibility: product workflow and protocol translation change independently;
- dependency inversion: application policy knows concrete transport machinery;
- interface segregation: the workflow receives far more capability than it needs; and
- information hiding: remote grammar is part of application code.
Choosing the largest number of principle names adds no value.
Pick the pressure which controls the decision:
We need to test the connect workflow without reproducing a dynamic remote call
chain. Introduce a two-operation lifecycle capability. Keep remote framing and
failure translation in its adapter.The other principles may describe the result. The change needs one causal argument.
A principle record needs evidence and a limit
Before invoking a principle in review, write five lines:
Observation:
Cost or risk:
Proposed boundary:
Prediction:
Limit:For the broad concrete client:
Observation: the caller uses five of fourteen public operations.
Cost or risk: unrelated provider surface can become an undeclared dependency.
Proposed boundary: receive the five-operation role from an interface-typed
factory.
Prediction: removing the other nine operations will not change this caller.
Limit: the interface is not a runtime capability sandbox and still exposes
provider-shaped results.This record is harder to wave away than “ISP violation.” It is also easier to disprove. If the nine methods always move with the five, the proposed split may be ceremony. If callers immediately downcast to the concrete type, the declared role is not the operative boundary.
Falsifiability is a feature. A principle which cannot lose against evidence can only express taste.
Let counterexamples travel with the rule
Most principle summaries preserve the preferred response and discard its reversal conditions.
Keep both:
| Principle shorthand | Pressure it remembers | Counterexample |
|---|---|---|
| one reason to change | unrelated edits propagate through one owner | one coordinator enforces one atomic sequence |
| depend on abstractions | policy knows replaceable machinery | one local implementation and caller change together |
| segregate interfaces | callers receive unrelated capabilities | one caller genuinely owns the full operation set |
| prefer immutable values | shared mutation escapes local reasoning | explicit mutation is the domain transition |
The counterexample prevents cargo-cult application. It also tells a reviewer what evidence would reverse the recommendation.
Test the compressed rule against another case
The public reproduction models four synthetic principle records. Each includes an observation, pressure, response, prediction, limit, and counterexample. It rejects records which contain only a principle label or which make no testable prediction.
The fixture does not prove the private cases or decide that a design is good. It checks whether the reasoning remains available after compression.
That gives principles a practical workflow:
- investigate the concrete behavior;
- name the change or ownership pressure;
- choose the smallest response;
- record what the response predicts;
- record when the advice reverses;
- use a principle name as the index; and
- revisit the record when the next change arrives.
The principle becomes institutional memory rather than an appeal to authority.
Keep the history recoverable
Short names matter. “Single responsibility,” “dependency inversion,” “interface segregation,” and “immutability” let engineers connect a new design problem to years of earlier work.
Compression is useful only while the original signal can still be recovered.
When a review says “this violates SOLID,” ask for the uncompressed version:
Which caller or invariant is under pressure?
What changed independently?
What concrete cost or risk followed?
What boundary would contain it?
What next change should become easier?
When would that boundary be worse?If those questions have no answer, the principle is premature. If they do, the principle gives the team a durable name for an argument grounded in its own evidence.
That is the point of a design principle: not to win the current discussion, but to preserve a failure history compactly enough that the next engineer can use it without repeating the failure.