Skip to main content
Low-Level Design · lesson 4 of 18
Open the Low-Level Design page →
Low-Level Design beginner Lesson 4 of 18

SOLID in Practice: The Open/Closed Principle

An if/elif area calculator silently drops a new shape's area to zero; a polymorphic one sums it correctly with zero changes to the calculator itself.

By SkillByExample · Published

lld oop python solid

Adding a new shape should never require editing the class that adds up areas — but it’s an easy mistake to make.

The naive version: an if/elif chain

import math

class Circle:
    def __init__(self, radius):
        self.radius = radius

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

class AreaCalculator:
    def total_area(self, shapes):
        total = 0
        for shape in shapes:
            if isinstance(shape, Circle):
                total += math.pi * shape.radius ** 2
            elif isinstance(shape, Rectangle):
                total += shape.width * shape.height
            else:
                print(f"Unknown shape: {type(shape).__name__}, treated as 0 area")
        return total

class Triangle:
    def __init__(self, base, height):
        self.base = base
        self.height = height

calc = AreaCalculator()
shapes = [Circle(2), Rectangle(3, 4), Triangle(5, 6)]
print(f"Total area: {calc.total_area(shapes):.2f}")
$ python naive_shapes.py
Unknown shape: Triangle, treated as 0 area
Total area: 24.57

Triangle is a perfectly valid class — it just isn’t one of the two AreaCalculator knows about. The real triangle area (0.5 * 5 * 6 = 15) silently vanishes from the total instead of raising an error, because AreaCalculator.total_area has to be told about every shape that will ever exist, and someone forgot to tell it about this one.

The fix: let each shape compute its own area

import math

class Circle:
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return math.pi * self.radius ** 2

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

class AreaCalculator:
    def total_area(self, shapes):
        return sum(shape.area() for shape in shapes)

class Triangle:
    def __init__(self, base, height):
        self.base = base
        self.height = height

    def area(self):
        return 0.5 * self.base * self.height

calc = AreaCalculator()
shapes = [Circle(2), Rectangle(3, 4), Triangle(5, 6)]
print(f"Total area: {calc.total_area(shapes):.2f}")
$ python shapes.py
Total area: 39.57

39.57 (pi*4 + 12 + 15) is the correct total, and AreaCalculator didn’t change one character to get it right — it never even imports math anymore. Every shape is responsible for its own area, so AreaCalculator can sum shapes it will never be updated to know about by name.

Practice

1. Add a Square shape without touching AreaCalculator.
import math

class Circle:
    def __init__(self, radius):
        self.radius = radius
    def area(self):
        return math.pi * self.radius ** 2

class Square:
    def __init__(self, side):
        self.side = side
    def area(self):
        return self.side ** 2

class AreaCalculator:
    def total_area(self, shapes):
        return sum(shape.area() for shape in shapes)

calc = AreaCalculator()
print(f"Total area: {calc.total_area([Circle(1), Square(4)]):.2f}")
$ python square.py
Total area: 19.14

3.14 + 16 = 19.14 — AreaCalculator.total_area is unchanged from the previous example, proving the “closed for modification” half of the principle.

2. Use an abstract base class so forgetting area() fails immediately.
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        ...

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    def area(self):
        return 3.14159 * self.radius ** 2

class BrokenShape(Shape):
    def __init__(self):
        pass

try:
    b = BrokenShape()
except TypeError as e:
    print(f"TypeError: {e}")

c = Circle(2)
print(f"Circle area: {c.area():.2f}")
$ python abstract_shape.py
TypeError: Can't instantiate abstract class BrokenShape without an implementation for abstract method 'area'
Circle area: 12.57

BrokenShape never even gets constructed — Python refuses at BrokenShape(), not later when something eventually calls .area() on it and hits an AttributeError far from where the real mistake was made.

3. Add total_perimeter following the same pattern.
import math

class Circle:
    def __init__(self, radius):
        self.radius = radius
    def area(self):
        return math.pi * self.radius ** 2
    def perimeter(self):
        return 2 * math.pi * self.radius

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
    def area(self):
        return self.width * self.height
    def perimeter(self):
        return 2 * (self.width + self.height)

class ShapeCalculator:
    def total_area(self, shapes):
        return sum(s.area() for s in shapes)
    def total_perimeter(self, shapes):
        return sum(s.perimeter() for s in shapes)

calc = ShapeCalculator()
shapes = [Circle(2), Rectangle(3, 4)]
print(f"Total area: {calc.total_area(shapes):.2f}")
print(f"Total perimeter: {calc.total_perimeter(shapes):.2f}")
$ python perimeter.py
Total area: 24.57
Total perimeter: 26.57

Same shape, two independent capabilities (area, perimeter) — ShapeCalculator still never branches on shape type for either one.

Next: a parking lot that has to allocate and free spots of different sizes without ever double-booking one.

Frequently Asked Questions

What does 'open for extension, closed for modification' actually mean here?
'Open for extension' means adding a new shape (Triangle) should be possible. 'Closed for modification' means adding it shouldn't require editing AreaCalculator's existing, already-tested code. The naive version fails the second half: every new shape means another elif branch inside a class that has nothing to do with shapes.
Why did the naive version return the wrong total instead of crashing?
The `else` branch printed a warning and silently contributed 0 to the total rather than raising an error. This is realistic and worse than a crash — a crash gets noticed and fixed immediately; a silently wrong total (24.57 instead of 39.57) can ship to production and go unnoticed until someone reconciles the numbers by hand.
Isn't `sum(shape.area() for shape in shapes)` just moving the same logic somewhere else?
No — the logic that computes each shape's specific area moved *into* that shape's own class, next to the data it needs (`radius`, `width`, `height`). `AreaCalculator` no longer knows anything about circles or rectangles at all; it just calls `.area()` and trusts whatever object it's given to implement it. That's the actual shift, not just code relocation.
When would I use Python's abstractmethod instead of just trusting every shape to implement area()?
Plain duck typing (just calling `.area()` and hoping it exists) fails at runtime with an `AttributeError` on the first shape that forgot to implement it. An abstract base class with `@abstractmethod` fails earlier and louder — at object *construction* time, as Practice #2 shows — which is worth the small extra ceremony once a codebase has enough shape classes that someone will eventually forget.