LearnLLDSOLIDO: Open closed

Open closed principle

In January the delivery fee function was 12 lines: a base fee plus a charge per kilometre. By October it was 190. Rain surcharge, late night surcharge, Diwali week, free delivery for members, a small order penalty, and a comment that said // do not touch, ask Karthik. Karthik left in August.

Every new rule was added by editing the same function. So every new rule was a chance to break the nine before it, and the only test was production.

Your answer

A delivery fee depends on distance, rain, and membership, and marketing adds a new rule about once a month. How would you structure the code so that next month's rule cannot break this month's?

The idea

Bertrand Meyer wrote it in 1988: software should be open for extension, closed for modification. You should be able to make it do something new without editing what is already there and working.

The picture to keep
An extension board

You buy an air cooler in April. You plug it into the board. You do not open the wall and rewire the house, and the fridge that was already plugged in is in no danger from your new purchase. The board was built with empty sockets on purpose.

In one line: Add behaviour by adding code, not by editing code that works.

Before: every rule edits the same function

type Order = {
  subtotal: number
  distanceKm: number
  isRaining: boolean
  isMember: boolean
}
 
function deliveryFee(order: Order): number {
  let fee = 20 + order.distanceKm * 8
  if (order.isRaining) fee += 25
  if (order.isMember) fee = 0
  // next month: late night, festival week, small orders...
  return fee
}
 
const order: Order = {
  subtotal: 240,
  distanceKm: 3,
  isRaining: true,
  isMember: false,
}
 
console.log(deliveryFee(order)) // 69

Nothing is wrong with this at three rules. The trouble is the direction it grows in. Rule ten goes into the same body, shares the same fee variable, and depends on where you put it relative to the other nine.

After: each rule is its own piece

type Order = {
  subtotal: number
  distanceKm: number
  isRaining: boolean
  isMember: boolean
}
 
interface FeeRule {
  apply(fee: number, order: Order): number
}
 
class DistanceRule implements FeeRule {
  apply(fee: number, order: Order): number {
    return fee + order.distanceKm * 8
  }
}
 
class RainRule implements FeeRule {
  apply(fee: number, order: Order): number {
    return order.isRaining ? fee + 25 : fee
  }
}
 
class MemberRule implements FeeRule {
  apply(fee: number, order: Order): number {
    return order.isMember ? 0 : fee
  }
}
 
class FeeCalculator {
  constructor(private readonly rules: FeeRule[]) {}
 
  calculate(order: Order): number {
    return this.rules.reduce((fee, rule) => rule.apply(fee, order), 20)
  }
}
 
const order: Order = {
  subtotal: 120,
  distanceKm: 3,
  isRaining: true,
  isMember: false,
}
 
const calculator = new FeeCalculator([
  new DistanceRule(),
  new RainRule(),
  new MemberRule(),
])
 
console.log(calculator.calculate(order)) // 69

FeeCalculator starts at the base fee of 20 and passes it through each rule in turn. It does not know what the rules are. It knows there is a list of things with an apply method.

Now marketing wants 15 rupees extra on orders under 150:

class SmallOrderRule implements FeeRule {
  apply(fee: number, order: Order): number {
    return order.subtotal < 150 ? fee + 15 : fee
  }
}
 
const updated = new FeeCalculator([
  new DistanceRule(),
  new RainRule(),
  new SmallOrderRule(),
  new MemberRule(),
])
 
console.log(updated.calculate(order)) // 84

One new class, one new entry in a list. FeeCalculator, DistanceRule, RainRule and MemberRule were not opened, so they cannot have been broken. The new rule can be tested alone with three lines.

Figure 1. The calculator is closed: it only knows the FeeRule interface. The set of rules is open, and the new one arrives without touching the others.

What “closed” does not mean

Something still changed: the list passed to new FeeCalculator(...). That is fine, and it is the point. Closed does not mean no line of the codebase is ever edited. It means the edit happens in a place built for it. A list of rules is a socket. A 190 line function body is the wall.

And the order of that list matters. MemberRule has to come last or a member would be charged the small order fee. The refactor did not remove that decision, it moved it to one visible line instead of leaving it buried in the order of if statements.

Closed also does not mean you cannot fix a bug. If RainRule charges the wrong amount, you open it and fix it.

The lighter TypeScript version

A rule here is one function with no state. In TypeScript you can say so directly, and skip the classes.

type Order = { distanceKm: number; isRaining: boolean }
type FeeRule = (fee: number, order: Order) => number
 
const distance: FeeRule = (fee, order) => fee + order.distanceKm * 8
const rain: FeeRule = (fee, order) => (order.isRaining ? fee + 25 : fee)
 
const rules: FeeRule[] = [distance, rain]
const order: Order = { distanceKm: 3, isRaining: true }
 
console.log(rules.reduce((fee, rule) => rule(fee, order), 20)) // 69

type FeeRule = (fee: number, order: Order) => number is a function type: anything that takes a number and an order and returns a number. The principle is the same, a list of interchangeable pieces. Use classes when a rule needs its own data or dependencies, like a festival calendar. Use functions when it does not.

Sockets nobody will use

You cannot be closed against every possible change, and trying gives you plugin systems for things that never vary. Wait for evidence. The first if is fine. The second is a note to yourself. The third rule of the same kind is when you extract the interface, because now you know what shape the variation has. Guessing that shape early is how codebases end up with a FeeRuleFactoryProvider.

Where does this come up in a design round?

Nearly every low level design question has one place built to test it: vehicle types in a parking lot, pricing rules, notification channels, payment methods. When you see a list of kinds that is sure to grow, say “this will change, so I will put it behind an interface and keep the caller closed”. That one sentence is the open closed principle applied, and it is worth more than the definition.

TypeScript you just picked up
interface FeeRule { apply(fee: number, order: Order): number }
The socket. Any class with this method can be plugged in.
constructor(private readonly rules: FeeRule[])
The calculator receives its rules. It never creates them, so it never needs editing.
rules.reduce((fee, rule) => rule.apply(fee, order), 20)
Thread one value through every rule. 20 is the starting fee.
type FeeRule = (fee: number, order: Order) => number
A function type. Parameter types, an arrow, then the return type.
const rain: FeeRule = (fee, order) => ...
The annotation on the variable types the parameters for you.
order.isRaining ? fee + 25 : fee
The conditional operator. Both branches must fit the return type.

Checkpoint

Checkpoint

1. After the refactor, a festival surcharge is needed. Which existing code has to be edited?

2. Does "closed for modification" mean you must never edit an existing class?

3. A function has one if for premium users and you do not expect more user tiers. Should you introduce a rule interface now?

Say this in 60 seconds

The open closed principle says code should be open for extension and closed for modification, so I can add behaviour without editing what already works. The typical violation is a function with an if ladder that grows by one branch per feature, where every new rule risks the old ones. The fix is to put the thing that varies behind an interface, like a fee rule, and have the caller work with a list of them. A new rule is then a new class and one entry in the list, and nothing existing is opened. It does not mean code is frozen, bug fixes still edit code, and I do not build extension points on a guess. I wait until I have seen the same kind of change about three times.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.