A benefits platform receives a new eligibility rule: dependents who turn twenty-six remain covered through the end of that month. The rule sounds local. The pull request is not. It changes the evaluator, an audit export, a batch-enrollment job, rejection messages in two interfaces, and tests owned by three teams.
The problem is not simply that seven files changed. A cohesive feature can legitimately span files. The problem is that one policy decision crossed modules serving different actors for different reasons. Compliance owns the decision, operations owns the export, product owns the explanation, and the batch team owns the nightly feed. Those responsibilities are moving together because the code put them together.
SOLID is best understood as five questions about that change surface. It is not a project template, a class-count target, or a demand to put an interface in front of every concrete type.
Start with the source of change
The Single Responsibility Principle is often reduced to “a class should do one thing.” That advice produces tiny objects without explaining where to draw the boundaries. Robert Martin's later formulation is more useful: group behavior that changes for the same actor, and separate behavior that changes for different actors.
In the eligibility system, determining coverage and explaining coverage are related, but they do not have the same source of change. Compliance changes the policy. Product and legal change the language. An audit schema changes because a regulator or an operations process requires different evidence. Keeping all three in EligibilityService means the service has several reasons to change.
type EligibilityDecision =
| Eligible { through: Date, reasons: Reason[] }
| Ineligible { reasons: Reason[] }
interface EligibilityPolicy {
evaluate(application: Application, on: Date): EligibilityDecision
}
interface DecisionExplainer {
explain(decision: EligibilityDecision, locale: Locale): Message
}
interface AuditRecorder {
record(applicationId: Id, decision: EligibilityDecision): void
}
The policy owns the decision. The explainer translates it for a person. The recorder preserves it for another actor. This is not automatically better because it has three interfaces. It is better only when those actors actually drive independent change.
Protect observed variation, not imagined futures
The Open/Closed Principle asks us to make useful behavior extensible without repeatedly editing stable policy. The dangerous word is useful. A team can invent endless future variation and build an extension framework for a product that has not found a customer.
Suppose the platform already applies two end-of-coverage rules because two regulated products have different contracts. A policy boundary now protects observed variation:
interface DependentCoverageRule {
coverageEnd(dependent: Dependent, contract: Contract): Date
}
final class EndOfBirthMonth implements DependentCoverageRule { ... }
final class EndOfCalendarYear implements DependentCoverageRule { ... }
That interface does not promise that every future regulation will fit. A new rule based on employment status may require a different input or a different model. Jon Skeet's critique of the Open/Closed Principle is important here: code can be open to changes we anticipated and still require modification for a change we did not. Revising the abstraction is not failure. Hiding a new meaning behind the old interface would be.
Substitution is a business promise
The Liskov Substitution Principle is not satisfied because two classes share a method signature. A replacement must honor the expectations on which its callers rely.
Imagine a PromotionalCoverageRule that implements DependentCoverageRule but throws when the member is in a renewal period. The type checker is satisfied. The batch job, which is entitled to evaluate every active contract, is not. The real contract includes preconditions, results, and failure behavior.
Write those expectations in domain terms:
contract DependentCoverageRule {
accepts every validated Contract
returns a date no earlier than enrollmentDate
returns the same result for the same facts and policyVersion
reports missing facts as a typed decision, never an infrastructure exception
}
Substitutability is valuable because it lets a new rule enter a known workflow without making every caller defensive. It is expensive because someone must define and test the behavioral contract rather than relying on structural typing alone.
Narrow interfaces around clients
Interface Segregation does not mean turning a gateway with six methods into six one-method interfaces by default. It means a client should not depend on capabilities it does not use.
The support console may need explainDecision and reevaluate. The nightly enrollment job may need evaluateBatch. If both receive an EligibilityAdministration interface that also exposes policy publication and audit deletion, their tests and permissions become coupled to irrelevant capabilities.
Split the contract where clients, security boundaries, or change histories differ. Do not split it simply to make every file small.
Let policy own its dependencies
Dependency Inversion is the bridge from local SOLID decisions to larger architectural boundaries. High-level policy should not be forced to speak in the vocabulary of a database driver, HTTP client, or vendor SDK.
interface HouseholdFacts {
loadFor(applicationId: Id): Result<Household, FactsUnavailable>
}
final class EligibilityPolicyService {
constructor(facts: HouseholdFacts, rules: RuleSet) { ... }
}
The policy defines the facts it needs. An adapter can obtain them from a database today and a state data service tomorrow. That direction matters more than the physical folder containing the interface. It also does not require one interface for every class. A stable calculation with no external dependency can remain concrete.
What it buys
Used selectively, SOLID can make the edit surface match the decision surface. A compliance rule changes policy code and policy tests. A wording change stays in the explainer. A new evidence format stays in the recorder. Reviewers can reason about a smaller set of consequences, and teams can run the relevant tests without constructing the whole application.
The quality-of-life benefit is a mechanism, not a guaranteed outcome: fewer unrelated files to understand, fewer owners needed for a routine review, and fewer regressions in behavior that had no reason to change.
What it costs
Every seam needs a name. Every behavioral contract needs tests. Indirection makes a call path harder to follow, especially when the abstraction has only one implementation and no independent reason to exist. Premature interfaces freeze guesses about variation. Tiny classes can scatter a use case across a directory. Mock-heavy tests can prove that collaborators were called while missing whether the business outcome is correct.
The most common failure modes are:
- treating SRP as one operation per class instead of one source of change;
- creating extension points before a second real variation exists;
- assuming inheritance is safe because signatures match;
- forcing clients to depend on broad administrative gateways;
- adding one-to-one interfaces that invert nothing;
- measuring design quality by object count rather than change containment.
When not to use it
Do not schedule a project-wide “SOLID refactor.” A short-lived experiment, a stable data transformation, or a small CRUD screen may be clearest as direct code. Do not add five layers to an MVP because the product might eventually need them. Keep a likely seam visible, collect change history, and extract it when the system earns the structure.
The smallest viable adoption is often one move: put a volatile business rule in one named module with framework-free tests. That creates an option without building an architecture program.
Sources
- Robert C. Martin, The Single Responsibility Principle.
- Barbara Liskov and Jeannette Wing, A Behavioral Notion of Subtyping, for substitutability defined through behavioral expectations rather than matching signatures.
- Robert C. Martin, SOLID Relevance, for the continuing role of client-specific interfaces and the other SOLID principles beyond class inheritance.
- Martin Fowler, DIP in the Wild.
- Jon Skeet, The Open-Closed Principle, in review.
- Luuk van der Werf and colleagues, Applying SOLID principles for the refactoring of legacy code, an industrial experience report rather than a universal performance claim.
- Nicole Forsgren and colleagues, The SPACE of Developer Productivity, for treating productivity as multidimensional rather than a single activity count.