33%

الدرس 1 من 3

One Reason to Change, and Extension Without Edits

The single-responsibility and open-closed principles, restated as questions you can answer about a real file: who asks for changes here, and what does adding the next variant cost?

قراءة 12 دقيقة

SOLID is an acronym for five design principles collected by Robert C. Martin in the early 2000s. They operate at the level of classes and modules, not whole systems — which makes them design principles in the Chapter 1 sense, with one exception we reach in the third lesson. Learning them as slogans is close to useless. Learning them as named failure modes, each with a symptom you can observe in a real repository, is worth a great deal.

The common misreading is that SRP means "small classes". It does not. A 600-line module that only ever changes when the pricing team changes pricing rules obeys SRP perfectly. A 40-line class that changes whenever finance changes a report layout *or* compliance changes a tax rule violates it, and the violation shows up as two teams colliding in one file and as regressions in one concern caused by edits to the other.

Two reasons to change hiding in one small class: finance owns the wording, compliance owns the rate.typescript
// Before: finance asks for wording changes, compliance asks for rate changes,
// a tax-law change breaks the printed layout's tests, and both teams edit here.
export class Payout {
  constructor(
    private readonly grossMinor: number,
    private readonly country: string,
  ) {}

  taxMinor(): number {
    // Compliance owns this. It changes when law changes.
    const rate = this.country === 'DE' ? 0.19 : this.country === 'GB' ? 0.2 : 0;
    return Math.round(this.grossMinor * rate);
  }

  toRemittanceAdvice(): string {
    // Finance owns this. It changes when the template changes.
    return `Gross ${(this.grossMinor / 100).toFixed(2)} — tax ${(this.taxMinor() / 100).toFixed(2)}`;
  }
}
After: each collaborator has exactly one group of people who can ask it to change.typescript
// Compliance's rules, testable against published rates and nothing else.
export interface TaxPolicy {
  taxMinorFor(grossMinor: number, country: string): number;
}

// Finance's presentation, testable with a fixed pair of numbers.
export interface RemittanceFormatter {
  format(payout: PayoutView): string;
}

export interface PayoutView {
  readonly grossMinor: number;
  readonly taxMinor: number;
  readonly currency: string;
}

// The domain object now only changes when what a payout *is* changes.
export class Payout {
  constructor(
    readonly grossMinor: number,
    readonly country: string,
    readonly currency: string,
  ) {}

  view(policy: TaxPolicy): PayoutView {
    return {
      grossMinor: this.grossMinor,
      taxMinor: policy.taxMinorFor(this.grossMinor, this.country),
      currency: this.currency,
    };
  }
}

Open-closed: what does the next variant cost?

The practical version is a cost question. When a new payout provider, a new country or a new report type arrives, how many existing files must you edit? If the answer is "one new file and one registration line", the module is effectively closed to modification. If the answer is "seven switch statements scattered across the codebase, and you will find the eighth in production", it is not.

Shape of the existing codeCost of the third providerRisk to the existing two
A switch on provider name at each of six call sitesSix edits, plus finding the sites nobody remembersHigh: every edit opens working code for the other two
One switch in a factory, providers behind an interfaceOne new class, one line in the factoryLow: existing classes are not opened at all
A registry populated at start-up from a provider listOne new class, one registration entryLow, and every registration is visible in one place
The same requirement — support a third payout provider — costs very differently depending on how the existing code is shaped.

In practice

Adding the third provider

A marketplace paid sellers through one bank integration for two years. A second was added under deadline by copying the first and adding an `if (provider === "wise")` branch at each of the five places the first was called. A third provider is now contractually committed for next quarter, and a fourth is likely.

Constraints

  • Five known call sites, and a suspicion there are more
  • Payout correctness is audited quarterly
  • One team of four owns the whole payouts area
  • The third provider must be live in ten weeks

Decision

Before adding the third provider, extract a `PayoutProvider` port containing the four operations the call sites actually use, move both existing integrations behind it, and delete the branches. Only then add the third as a new adapter.

Why

The refactor is bounded: it is mechanical, it is covered by the existing audit tests, and it converts a growing per-provider cost into a flat one. Adding the third provider first would have created fifteen branches and the fourth would have been worse. The evidence that this axis varies is contractual rather than speculative, which is what justifies the extension point.

What it cost

It cost three weeks up front and constrained the design: the port is the lowest common denominator of the providers, so a provider-specific capability — one vendor's batch payout endpoint — cannot be used without widening the interface for everyone. The team accepted losing that optimisation in exchange for a bounded cost per provider, and recorded that they would revisit if batching became a requirement.

Reading a pull request that adds a fourth `if (type === ...)`

As a developer

Approves it. The change is small, correct and consistent with the three branches already there. Asking for a refactor would expand the scope of somebody else's pull request for a principle that is not obviously paying for itself yet.

As an architect

Asks how many call sites now carry the same branch and whether the list of types is still growing. If it is the fourth branch across six sites with two more types on the roadmap, the cost curve is visible and it is time to extract a port. If it is the second branch in one place and the list is closed, approving is correct. The decision comes from growth evidence, not from disliking conditionals.

A colleague says a 700-line pricing module "obviously violates SRP because it is huge". How do you respond?Reveal

Ask who asks for changes to it. If every change originates from the pricing team responding to pricing rules, it has one reason to change and SRP is satisfied — it may still be worth splitting for readability, but that is a different argument needing different evidence. If instead the file changes when pricing changes, when tax rules change and when the invoice layout changes, its size is irrelevant and the three stakeholders are the problem. Size is a symptom that sometimes accompanies the disease; it is not the disease.

Key takeaways

  • SRP counts reasons to change — meaning people who can request a change — not lines or abstract responsibilities.
  • The best evidence of an SRP violation is version history: one file appearing in unrelated stakeholders' pull requests.
  • OCP is a cost question: how many existing, tested files must be edited to add the next variant?
  • You can only be open to a few axes of change, so pick axes with real evidence of variation rather than imagined ones.
  • Extracting an extension point is justified by a known upcoming variant, not by a dislike of conditionals.