Conflicts and limits — deciding which one to take
So far we have looked at the five principles one at a time. Read individually, each of them seems reasonable. The trouble comes when you try to apply them at once.
Push splitting and one change touches more files. Interpose an abstraction and it takes more steps to trace what actually runs. Each principle is correct, and they face opposite directions. This chapter covers the criteria for deciding when they conflict.
An account that organizes the five principles uniformly through the two yardsticks of cohesion and coupling is in PHP Class Design Guide — Cohesion, Coupling, and SOLID Altogether. This chapter takes that map as given and handles the problem the map does not answer — which one to take when the operation that raises cohesion and the operation that lowers coupling collide in the same code.
Principles conflict with other desirable things
Conflict is not the exception. The original itself writes that principles can be mutually exclusive. On the three principles concerning cohesion in package design, Martin's 2000 document says this1.
These three principles are mutually exclusive. They cannot simultaneously be satisfied. That is because each principle benefits a different group of people.
It goes on to write that the priority shifts over time: early in a project you favor a structure that is easy to develop and maintain, and once the architecture has settled you reorganize for outside consumers.
It is not that "when principles conflict, one of them is wrong." The structure is that the answer changes with whose interest you put first.
For the five principles of class design, what stands in opposition is not a different group of people but a different cost the same developers pay.
| Principle × what it opposes | What it is | Clues for deciding |
|---|---|---|
| SRP × locality of change | The further you split, the more files one change opens | Does a change stay in one file, or always scatter across several |
| OCP × YAGNI | Prepare an extension point first and, when the prediction is wrong, it costs more than the branch did | Is the direction of extension observed, or imagined |
| DIP × traceability | The more abstractions you interpose, the more steps it takes to trace what runs at runtime | Is substitution actually happening |
| ISP × total number of types | The finer you slice, the more types and implements there are | Are the clients' usages really divided |
| LSP × inheritance as a means | Use inheritance for a relationship that needs no substitution and only the contract's constraints remain | Is substitution needed. If not, stop inheriting and delegate |
LSP alone is not a trade-off whose degree you get to choose. As seen in chapter 4, the options are not "give up on LSP" but "stop inheriting" or "redesign the contract."
As the previous chapters showed, the five principles are not parallel either. The original treats an LSP violation as a latent OCP violation and positions DIP as the means of achieving OCP. What the original ties to OCP is these two, LSP and DIP. There is no comparable positioning in the original for SRP and ISP.
Symptoms of over-application
A state of having followed a principle too far has a common look. Each shows up in the form of "it complies with the principle and is still hard to read."
A high proportion of abstractions with only one implementation. If the number of interfaces and the number of implementation classes are nearly equal, the abstractions may have been built on prediction. That is chapter 3's predictive abstraction.
Many files to open to trace one operation. If learning "what happens before an order is confirmed" means moving across eight files, the split does not match the unit of change.
Abstraction names that are just the concrete name plus a suffix. A pair like LendingRecordStore and LendingRecordStoreImpl is a sign that the abstraction does not represent a concept. If you cannot name it, there was no boundary to split there.
More types and interfaces than the concepts being handled. If vocabulary grows but all that grew is the number of concepts the reader has to remember, it is not aiding understanding.
None of these is a violation of a principle. SRP, ISP, and DIP are all satisfied. If it is satisfied and still hard to read, you are applying it too far.
Thinking in properties rather than principles
There is criticism of SOLID itself. The most coherent presentation of it is CUPID, written by Dan North in 20222.
North takes issue with the framework of principles itself.
Principles are like rules: you are either compliant or you are not. This gives rise to "bounded sets" of rule-followers and rule-enforcers rather than "centred sets" of people with shared values.
What he puts in their place is properties.
Properties define a goal or centre to move towards. Your code is only closer to or further from the centre, and there is always a clear direction of travel.
Not the binary of violation or compliance, but how close you are to the center. That is the core of North's proposal. The five properties he lists are Composable / Unix philosophy / Predictable / Idiomatic / Domain-based, and every one of them admits degree.
The details of each CUPID item are left to the original. What matters for this series is that the form of the judgment changes. The question "does this design violate SRP" has a binary answer, and answering it does not settle what to do next. The question "is this code moving in the direction of being easier to predict" can be answered in degrees, and it shows what to do next.
North does not declare SOLID wrong as a whole. The article opens with him asking himself, "if you think SOLID is not that useful today, what would you replace it with?" and "can any set of principles apply to all software?" On SRP, however, a footnote clearly rejects it as easy to refute and as "an arbitrary constraint that often invites premature separation."
Why the five principles still stand
We will not stop at presenting the criticism. The problems the five principles point at have not gone away.
| Principle | The problem it points at | Does it still happen |
|---|---|---|
| SRP | Unrelated requests land in the same code and collateral changes occur | Yes |
| OCP | Every addition means fixing several places, and you forget one | Yes |
| LSP | You think you substituted, and it betrays the caller's expectations | Yes |
| ISP | A dependency on what you do not use widens the blast radius | Yes |
| DIP | A dependency on something volatile drags the higher level along | Yes |
What changed is not the problems but the standing of the principles. They moved from "rules to apply always" to "tools to use when the problem is actually happening."
Used as tools, there is an order. First identify the problem, then choose which principle bites. The reverse order — choosing the principle first and hunting for places it applies — is the gateway to over-application.
The order of judgment
Here is the judgment for a conflict, as a procedure.
1. Say what hurts, first. Check that you can say "we fixed it on accounting's request and the floor's screen changed, and we got a support call," not "it violates SRP." If you cannot name the pain, you have no reason to fix it.
2. Count how many times that pain has actually occurred. If it has not happened once, it is a prediction. As seen in chapter 3, a cost paid on prediction is paid daily.
3. Choose the principle as a tool. Collateral changes → SRP; a forgotten fix → OCP; a betrayal of substitution → LSP. You pull backward from the pain.
4. After applying it, look at whether a different pain has grown. Did splitting increase the steps to trace? Did interposing an abstraction hide what actually runs? Conflicts appear after you apply.
5. Try the more reversible direction first. The decision not to split can be handled by splitting later, whereas putting a split back together costs as much as the call sites are scattered. Abstractions are the same: interposing one later is cheaper than peeling off a wrong one.
What these steps have in common is not starting from the principle. A principle is a tool for judgment, not the judgment itself.
Summary
- The original itself writes, about the three cohesion principles of package design, that they are mutually exclusive and cannot be satisfied at once. It does not write that about the five principles of class design, but the trade-off between a principle and other desirable things does happen in practice. Conflict is not the exception
- The five principles are not parallel. What the original ties to OCP is LSP and DIP — LSP as its premise, DIP as its means. There is no comparable positioning in the original for SRP and ISP
- Over-application has a look: abstractions with one implementation, the number of files to open to trace, the
Implsuffix, more types than concepts - Dan North criticizes the framework of principles itself and proposes properties, seen in degrees rather than judged by binary compliance
- The problems the five principles point at still happen. What changed is the standing, from "rules always followed" to "tools chosen according to the pain"
- Start the judgment from the pain. Starting from the principle and hunting for places it applies is the gateway to over-application
What to read next
- SOLID Principles in Depth — chapter outline — choose which principle's chapter to go back to
- OCP and predictive abstraction — the chapter that handles this chapter's "OCP × YAGNI" decision, including the style that catches missed additions with the type
- Clean Code Guide — SOLID Principles — the definitions of the five principles, with rewrites from violating to improved examples
- PHP Class Design Guide — Cohesion, Coupling, and SOLID Altogether — the chapter that organizes the five principles uniformly through two yardsticks
- Clean Code Guide — Classes and Interfaces — when you want to go back and check the basics of class design
Exercises
Two principles conflict in the following design. Which do you put first, and how do you explain the decision?
The following request came in for a library system's overdue notices.
We want the wording of the notice to differ by membership tier. There are three tiers now — general, student, and senior — and faculty are scheduled to be added next year.
The current implementation has branches inside one function.
type MemberTier = "general" | "student" | "senior";
function buildOverdueMessage(tier: MemberTier, days: number, fine: number): string {
if (tier === "student") {
return `Your item is ${days} days past due. Please bring your student ID. The late fee is ${fine} yen.`;
}
if (tier === "senior") {
return `Your item is ${days} days past due. If you have any questions, please come to the desk. The late fee is ${fine} yen.`;
}
return `Your item is ${days} days past due. The late fee is ${fine} yen.`;
}
Option A: carve notice generation per membership tier into an interface and create an implementation class per tier (putting OCP first)
Option B: leave the branches as they are and have Record<MemberTier, ...> guarantee only exhaustiveness through the type (putting YAGNI first)
Sample answer
Option B is recommended. The basis for the decision is not "because it is less work," however.
Start from the pain. The problem occurring now is that "adding faculty requires fixing this function." Having to fix it is not itself the pain. The pain is forgetting to fix it.
Because the final return in the current code acts as the default, adding faculty produces the general member's wording with nothing happening at all. That is the real problem. It can be solved without introducing an abstraction.
const messageTemplates: Record<MemberTier, (days: number, fine: number) => string> = {
general: (days, fine) => `Your item is ${days} days past due. The late fee is ${fine} yen.`,
student: (days, fine) =>
`Your item is ${days} days past due. Please bring your student ID. The late fee is ${fine} yen.`,
senior: (days, fine) =>
`Your item is ${days} days past due. If you have any questions, please come to the desk. The late fee is ${fine} yen.`,
};
function buildOverdueMessage(tier: MemberTier, days: number, fine: number): string {
return messageTemplates[tier](days, fine);
}
Add "faculty" to MemberTier and the type of messageTemplates no longer matches, producing a compile error. A forgotten fix cannot happen.
The reason not to choose option A is that the problem an abstraction solves is not present here. Introduce an interface and implementation classes and you get one more file per tier, and fixing one piece of wording means hunting for the relevant class. What you gain is "you need not open existing files when adding a new tier," but a new tier arrives once a year at most. Comparing the wording side by side happens far more often.
How to explain the decision: "Following OCP gives you A, but extension happens about once a year whereas being able to see all the wording at once is needed daily. With B the type catches a missed addition, so the accident OCP wanted to prevent does not occur. We move to A once the wording per tier gets complex and starts needing conditionals or data lookups."
Add the conditions that would change the decision as well. If the per-tier notice takes on differences in processing, such as "also notify the guarantor for students," a table of wording cannot express it. At that point A becomes appropriate.