Client code directly instantiates concrete subclasses with `new TermLoan(...)`, `new Revolver(...)`. Every client knows the full taxonomy; adding a subclass requires touching every place. The team's verification cost compounds across every client that must be checked, the search cost of finding every concrete reference scales with codebase size, and a missed update produces a stale construction site that ships.
A single factory (a method on the shared superclass or a dedicated factory class) owns the knowledge of which subclasses exist. Clients call `Loan.newTermLoan(...)` and never see the concrete classes; the hierarchy can be reshaped freely as long as the factory's contract holds, supporting separation of concerns between client intent and construction mechanism.
Before the refactoring
// Concrete subclasses exported and constructed by clients everywhere.export class Loan {capital() { throw new Error('abstract'); }}export class TermLoan extends Loan {constructor(commitment, maturity) { super(); this.commitment = commitment; this.maturity = maturity; }capital() { return this.commitment * this.duration() * 0.05; }}export class Revolver extends Loan {constructor(commitment, expiry) { super(); this.commitment = commitment; this.expiry = expiry; }capital() { return this.commitment * 0.7 * this.duration() * 0.05; }}export class AdvisedLine extends Loan {constructor(commitment, expiry) { super(); this.commitment = commitment; this.expiry = expiry; }capital() { return this.outstanding() * this.duration() + this.unused() * this.duration() * 0.5; }}// Client code (in many files):const loan1 = new TermLoan(100000, '2027-01-01');const loan2 = new Revolver(50000, '2026-06-30');const loan3 = new AdvisedLine(75000, '2026-06-30');
After the refactoring
// Only Loan is exported. Concrete subclasses stay module-local.export class Loan {static newTermLoan(commitment, maturity) {return new TermLoan(commitment, maturity);}static newRevolver(commitment, expiry) {return new Revolver(commitment, expiry);}static newAdvisedLine(commitment, expiry) {return new AdvisedLine(commitment, expiry);}capital() { throw new Error('abstract'); }}class TermLoan extends Loan { /* ... */ }class Revolver extends Loan { /* ... */ }class AdvisedLine extends Loan { /* ... */ }// Client code:const loan1 = Loan.newTermLoan(100000, '2027-01-01');const loan2 = Loan.newRevolver(50000, '2026-06-30');const loan3 = Loan.newAdvisedLine(75000, '2026-06-30');
The subclass taxonomy leaks into every call site. Renaming, splitting, or merging concrete classes is Shotgun Surgery on the entire client population; knowledge of which concrete class fits a given role duplicates across files that should not have to care. The blast radius of any hierarchy edit stretches into every client that named a subclass.
Factory methods proliferate as the hierarchy grows; readers must consult the factory to know what concrete behaviour the returned object will have. The hierarchy becomes an indirection layer between intent and instance, and the team's maintenance cost reshapes rather than vanishes — the factory itself becomes the place coordinated edits land when the product set changes.
Clients depend on the abstract type and the factory's named methods; concrete classes can be reshaped without client awareness. New subclasses ship by adding one factory method, not by editing the call-site population. The team's enhancement cost drops because adding a variant is one factory edit plus one new class rather than N call-site edits.
A factory that exposes exactly one named method per subclass is just constructor syntax with extra steps. The encapsulation pays only when the concrete subclasses can be merged, split, or substituted independently of the call-site contract — otherwise the indirection adds accidental complexity without flexibility, and readers carry the cognitive load of a hop that returns no domain meaning.