DEV Community

Christian Vasquez
Christian Vasquez

Posted on AI-assisted

How I finally understood the Open/Closed Principle

The Intro

Part two of our SOLID road trip. Last time we took a class with three jobs and gave it two coworkers. Today we are going after a different kind of monster: the if/else chain that grows a new branch every sprint.

Letter O: the Open/Closed Principle. Sounds like a sign on a shop door. It kind of is.

The What

Textbook time:

Software entities (classes, modules, functions) should be open for extension, but closed for modification.

Open and closed at the same time? Thanks, Schrödinger.

Here's the plain-English version that made it click for me: you should be able to add a new behavior without editing the code that already works. Add a file, not an else.

To feel it, we are going to build a discount calculator for an online store. Business starts simple:

  • Students get 10% off.
  • That's it. One rule. Life is good.

The How

Version one, straight from the heart:

// src/discount.ts
export interface Customer {
  type: 'regular' | 'student'
}

export function discountFor(customer: Customer, subtotal: number): number {
  if (customer.type === 'student') {
    return subtotal * 0.1
  }
  return 0
}
Enter fullscreen mode Exit fullscreen mode
// src/main.ts
import { discountFor } from './discount.js'

console.log(discountFor({ type: 'student' }, 100)) // 10
console.log(discountFor({ type: 'regular' }, 100)) // 0
Enter fullscreen mode Exit fullscreen mode

Clean. Tiny. Everyone claps. 👏

Then marketing shows up. Every. Single. Week.

  • Week 2: "VIP members get 20%."
  • Week 3: "Orders over $200 get 5% extra, but only for regulars."
  • Week 4: "Black Friday! 30% for everyone, but just on the 28th."
  • Week 5: "Can students AND VIPs stack? No wait, only the biggest one. Actually, let's talk."

Watch what happens to our innocent function:

// src/discount.ts
export interface Customer {
  type: 'regular' | 'student' | 'vip'
}

export function discountFor(customer: Customer, subtotal: number, today = new Date()): number {
  let discount = 0

  if (customer.type === 'student') {
    discount = subtotal * 0.1
  } else if (customer.type === 'vip') {
    discount = subtotal * 0.2
  } else if (subtotal > 200) {
    discount = subtotal * 0.05
  }

  const isBlackFriday = today.getMonth() === 10 && today.getDate() === 28
  if (isBlackFriday) {
    discount = Math.max(discount, subtotal * 0.3)
  }

  return discount
}
Enter fullscreen mode Exit fullscreen mode

Every new rule means:

  • Re-opening a function that was already working.
  • Re-reading the whole thing to make sure the new branch doesn't fight with the old ones.
  • Re-running every existing test because, let's be honest, you touched everything.

And the function knows about dates now. Why does a discount calculator know what day it is? 🤨

This is a function that is open for modification. Every change goes inside it. The principle says: let's flip that.

Step 1: describe what a rule looks like

All these rules have the same shape: "given a customer and a subtotal, how much do I knock off?". Let's write that down:

// src/discount-rule.ts
import type { Customer } from './customer.js'

export interface DiscountRule {
  apply(customer: Customer, subtotal: number): number
}
Enter fullscreen mode Exit fullscreen mode

Step 2: one small class per rule

// src/rules/student-discount.ts
import type { DiscountRule } from '../discount-rule.js'
import type { Customer } from '../customer.js'

export class StudentDiscount implements DiscountRule {
  apply(customer: Customer, subtotal: number): number {
    return customer.type === 'student' ? subtotal * 0.1 : 0
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/rules/vip-discount.ts
import type { DiscountRule } from '../discount-rule.js'
import type { Customer } from '../customer.js'

export class VipDiscount implements DiscountRule {
  apply(customer: Customer, subtotal: number): number {
    return customer.type === 'vip' ? subtotal * 0.2 : 0
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/rules/big-order-discount.ts
import type { DiscountRule } from '../discount-rule.js'
import type { Customer } from '../customer.js'

export class BigOrderDiscount implements DiscountRule {
  apply(customer: Customer, subtotal: number): number {
    return customer.type === 'regular' && subtotal > 200 ? subtotal * 0.05 : 0
  }
}
Enter fullscreen mode Exit fullscreen mode

Each file is boring on purpose. You can read one in five seconds and know exactly what it does.

Step 3: the calculator stops caring about rules

// src/discount-calculator.ts
import type { DiscountRule } from './discount-rule.js'
import type { Customer } from './customer.js'

export class DiscountCalculator {
  constructor(private readonly rules: DiscountRule[]) {}

  discountFor(customer: Customer, subtotal: number): number {
    const discounts = this.rules.map((rule) => rule.apply(customer, subtotal))
    return Math.max(0, ...discounts)
  }
}
Enter fullscreen mode Exit fullscreen mode

Look at that. No if about students. No if about VIPs. It just asks every rule "what do you think?" and keeps the best answer. The business rule "customers get the biggest discount, no stacking" lives in one line.

Step 4: assemble at the front door

// src/main.ts
import { DiscountCalculator } from './discount-calculator.js'
import { StudentDiscount } from './rules/student-discount.js'
import { VipDiscount } from './rules/vip-discount.js'
import { BigOrderDiscount } from './rules/big-order-discount.js'

const calculator = new DiscountCalculator([
  new StudentDiscount(),
  new VipDiscount(),
  new BigOrderDiscount(),
])

console.log(calculator.discountFor({ type: 'student' }, 100)) // 10
console.log(calculator.discountFor({ type: 'regular' }, 250)) // 12.5
Enter fullscreen mode Exit fullscreen mode

Step 5: the Black Friday test

Here comes the moment of truth. Marketing wants Black Friday. What do we touch?

// src/rules/black-friday-discount.ts
import type { DiscountRule } from '../discount-rule.js'
import type { Customer } from '../customer.js'

export class BlackFridayDiscount implements DiscountRule {
  constructor(private readonly today: () => Date = () => new Date()) {}

  apply(_customer: Customer, subtotal: number): number {
    const date = this.today()
    const isBlackFriday = date.getMonth() === 10 && date.getDate() === 28
    return isBlackFriday ? subtotal * 0.3 : 0
  }
}
Enter fullscreen mode Exit fullscreen mode

And one line in main.ts:

new BlackFridayDiscount(),
Enter fullscreen mode Exit fullscreen mode

That's the entire change. DiscountCalculator was not opened. StudentDiscount was not opened. Their tests didn't even notice something happened.

Closed for modification, open for extension. We extended the system by adding a file.

The Why

What did we actually gain?

  • New rules don't risk old rules. The student discount literally cannot break because you added Black Friday. They live in different files.
  • Each rule is testable alone. new StudentDiscount().apply({ type: 'student' }, 100) → 10. Done. No giant table of cases for one mega-function.
  • The date thing got cleaner by accident. Only BlackFridayDiscount cares about dates, and it takes a today() function so you can test it without changing your system clock.
  • Deleting a rule is a delete, not a surgery. Marketing cancels the VIP program? Remove one line from main.ts, delete one file. No else branches to re-balance.

Now, the honest caveat: you don't do this on day one with a single rule. The first version with one if was fine. The signal to refactor is the second time you open the same function to add a sibling branch. Once you see the pattern, pull the rules out before week five arrives.

The Goodbye

Two letters down, three to go. Next up is the L, Liskov Substitution, where we find out why a subclass that throws "not supported" is a trap, and what to do instead.

Go count the else if branches in your biggest function. If you need two hands, you know what to do. Happy coding 🤝

Top comments (0)