Open/Closed Principle: A Practical Example

The SOLID Principles are a valuable collection of concepts to keep in mind when evaluating code, and designing complex systems. Unfortunately, most examples you find online regurgitate the exact same examples about Shapes and Rectangles that don't relate to the code you would actually write.

The Open/Closed Principle is primarily about making features of your software system extensible, so that your teammates, at any point in the future, can add new behaviors or properties for a feature without having to modify the core logic of how it operates.

Even that's vague, so let's look at an example.

The questions I ask myself when designing a feature is, "Is it possible that new options for this feature will be needed in the future? If so, how can I write code that will allow someone else to add an option that is isolated from the core code that makes the feature work."

One of the projects that I coach students through at NSS is a simple CLI application that displays menu options for a customer service representative to manage fictional products and orders for fictional customers.

To get them prepared, I show them a simplistic CLI app that involves a bank account, and an automated bank teller. The bank account provides methods for adding money to it, withdrawing money from it, and showing the balance.

import locale

class BankAccount():

  def __init__(self):
    self.balance = 0
    self.account = None

  def add_money(self, amount):
    """Add money to a bank account

    Arguments:
      amount - A numerical value by which the bank account's balance will increase
    """
    self.balance += float(amount)

  def withdraw_money(self, amount):
    """Withdraw money to a bank account

    Arguments:
      amount - A numerical value by which the bank account's balance will decrease
    """
    pass
    self.balance -= float(amount)

  def show_balance(self):
    """Show formatted balance

    Arguments:
      None
    """
    locale.setlocale( locale.LC_ALL, '' )
    return locale.currency(self.balance, grouping=True)

The automated bank teller is the user interface for accessing the functionality of the bank account.

Here's how someone might start out building a menu system in Python. Notice the use of the multiple if statements in the main_menu method. This could very easily be a switch statement as well.

import os
from bank import BankAccount


class Teller():
  """This class is the interface to a customer's bank account"""

  def __init__(self):
    self.account = BankAccount()

  def build_menu(self):
    """Construct the main menu items for the command line user interface"""

    # Clear the console first
    os.system('cls' if os.name == 'nt' else 'clear')

    # Print all the options
    print("1. Add money")
    print("2. Withdraw money")
    print("3. Show balance")
    print("4. Quit")

  def main_menu(self):
    """Show teller options"""

    # Build the menu
    self.build_menu()

    # Wait for user input
    choice = input(">> ")

    # Perform the appropriate actions corresponding to user choice
    if choice == "1":
      deposit = input("How much? ")
      self.account.add_money(deposit)

    if choice == "2":
      withdrawal = input("How much? ")
      self.account.withdraw_money(withdrawal)

    if choice == "3":
      print(self.account.show_balance())
      input()

    # If the user chose anything except Quit, show the menu again
    if choice != "4":
      self.main_menu()


if __name__ == "__main__":
  teller = Teller()
  teller.main_menu()

As soon as I write this code, I realize that every time my product owner wants to do one of the following actions:

  1. Change the order in which the menus are displayed
  2. Add a new menu option
  3. Remove a menu option

Then a developer would need to come into this core logic and start moving code around, adding code, or deleting code. The possible opportunities for introducing bugs in that scenario are vast. You also could potentially end up with a main_menu method that is hundreds of lines long over years of development. That may never happen, but the possibility is always there.

Not only does this violate the Open/Closed Principle, but it also violates the Single Responsibility Principle. The main_menu method should be responsible for building the menu - nothing more. All of the logic for each option should not be contained in this method.

In my mind, the Open/Closed Principle and the Single Responsibility Principle are like two sides of the same coin. They work in tandem to make an extensible system.

Ok, so now I lean back in my chair and start considering what are the different responsibilities in this context, and how can I refactor this code so that it is open for extension, and closed for modification as much as possible. Remember, there is no perfect solution for any of this, just your best effort every time. You keep getting better at it as the years go by.

Responsibilities

  1. Something should display a collection of menu options
  2. Something should provide a method to build a list of menu options
  3. Every option should perform the logic required to achieve the goal

That's it. I then have a plan.

  1. I need to write code that will look at a collection of menu options, regardless of what the user interface is, or the logic each needs to perform, and display a prompt.
  2. That means that I need to build a class representing a menu option. It must expose a property that holds the string prompt for the menu.
  3. I need a class who is responsible for constructing a collection of menu options.

The Menu Option

My first step is to create a class represents a menu item. I decide that a menu item has two parts.

  1. The prompt
  2. The logic to perform when it is chosen
class MenuItem():

    def __init__(self, prompt, action):
        self.prompt = prompt
        self.action = action

This got me thinking what an action would be. How do I pass logic in as an argument to a method???

Hm, maybe I can pass in a class that follows a specific pattern that I can invoke when the logic needs to run. Luckily, Python classes have a dunder method named __call__ that allows you to invoke a class. It makes them callable. That's weird, but it works for me.

I'll start with the logic for showing the balance of a bank account.

import os


class ShowBalance():
    def __init__(self, account):
        self.account = account

    def __call__(self):
        os.system('cls' if os.name == 'nt' else 'clear')
        print("\n   Your balance is: {}".format(self.account.show_balance()))
        input(">> Press enter to continue <<")

Now I can initialize this class and pass in a reference to the bank account, and then call the class later to run the logic.

Now for the deposit.

class Deposit():
    def __init__(self, account):
        self.account = account

    def __call__(self):
        amount = input("How much? ")
        self.account.add_money(amount)

Lastly, withdrawal.

class Withdraw():
    def __init__(self, account):
        self.account = account

    def __call__(self):
        amount = input("How much? ")
        self.account.withdraw_money(amount)

The Menu Builder

Time to define the object who will hold menu items, and then display them when needed.

import os


class MenuBuilder():
    """Responsible for building a command line menu system from MenuItems"""

    def __init__(self, *args):
        self.__menu = list()

        for item in args:
            self.__menu.append(item)

    def add(self, menu_item=None, menu_items=None):
        if menu_items is not None:
            self.__menu.extend(menu_items)

        if menu_item is not None:
            self.__menu.append(menu_item)

    def show(self):
        # Clear the console
        os.system('cls' if os.name == 'nt' else 'clear')

        # Display each menu item
        for index, menu_item in enumerate(self.__menu):
            try:
                print("{}. {}".format(index+1, menu_item.prompt))
            except AttributeError:
                raise AttributeError('Could not display the prompt for the current menu item {}'.format(str(menu_item)))

        try:
            choice = int(input(">> "))

            # Invoke the class corresponding to the choice
            for menu_item in self.__menu:
                if choice == self.__menu.index(menu_item) + 1:
                    menu_item.action()

        except KeyboardInterrupt:   # Handle ctrl+c
            exit()

        except ValueError:    # Handle any invalid choice
            pass

        self.show()    # Display the MenuItems

Bank Teller

This makes the code for the bank teller much simpler.

from bank import BankAccount
from menu.actions import ShowBalance
from menu.actions import Deposit
from menu.actions import Withdraw
from menu.menubuilder import MenuBuilder
from menu.menuitem import MenuItem

class Teller():
    """This class is the interface to a customer's bank account"""

    def __init__(self):
        # Using composition to establish relationship between the bank
        # and the teller, as well as the teller and the CLI menu that
        # serves as the UI
        self.account = BankAccount()
        self.menu = MenuBuilder(
            MenuItem("Add Money", Deposit(self.account)),
            MenuItem("Withdraw Money", Withdraw(self.account)),
            MenuItem("Show Balance", ShowBalance(self.account)),
            MenuItem("Quit", exit)
        )
        self.menu.show()

Now the logic for each action in the menu is isolated into its own class, and the Teller class simply states which menu items should be displayed. This is definitely a step in the right direction. However, have I truly made this an Open/Closed system?

Not yet.

Any change to the menu system would still require a developer to open the Teller class and rearrange the logic. I've made progress, but there's more work to do. A developer should be able to add a MenuItem in the system, and the menu building system should be able to recognize it and show it where appropriate.

How can I do that in Python?

What if I made the menu actions a package, and the menu builder automatically imported everything from the package and built a menu from it? That's would be freaking cool, and since Python is awesome, you can do it easily.

/menu/actions/__init__.py

In the package init module, Python provides a way to retrieve every class in the package that inherits from a particular base class using the __subclasses__ dunder method.

import os
import pkgutil
import importlib
from .action import BaseAction


# Get the directory name of the current package
pkg_dir = os.path.dirname(__file__)

# Import each module
for (module_loader, name, ispkg) in pkgutil.iter_modules([pkg_dir]):
    importlib.import_module('.' + name, __package__)

# Since each menu action class is a subclass of BaseAction, I can
# build a dictionary of all classes, in all modules, in this package
all_actions = {cls.__name__: cls for cls in BaseAction.__subclasses__()}

# Now anywhere I want to use all classes, I can use the following code
#
# import menu.actions
#
# for k,v in menu.actions.all_actions.items():
#   ...do something awesome with each one

For this to work, each of the menu actions needs to have a parent class of BaseAction.

action.py

class BaseAction(object):
    pass

deposit.py

from .action import BaseAction


class Deposit(BaseAction):
    def __init__(self, account):
        self.account = account

    @property
    def prompt(self):
        return "Add Money"

    def __call__(self):
        amount = input("How much? ")
        self.account.add_money(amount)

My Teller class can now iterate over all of the classes in the menu.actions package.

from bank import BankAccount
from menu.menubuilder import MenuBuilder
from menu.menuitem import MenuItem
import menu.actions


class Teller():
    """This class is the interface to a customer's bank account"""

    def __init__(self):
        # Using composition to establish the relationship between the bank
        # and the teller, as well as the teller and the CLI menu that
        # serves as the UI
        self.account = BankAccount()

        # Initialize the menu builder
        self.menu = MenuBuilder()

        # Iterate over all of the classes in menu.actions package
        for action_class in menu.actions.all_actions.values():

            # Initialize each menu action and pass in the bank account
            action = action_class(self.account)

            # Add the menu action to the menu builder
            self.menu.add(MenuItem(action.prompt, action))

        self.menu.add(MenuItem("Quit", exit))
        self.menu.show()

First Open/Closed Run

My first attempt at running the code with this new system.

384aBBIIdm

Holy shit, it works right away.

Now for the real test. I'm going to just drop a new module file into the package and let's see if it shows up on the menu. This new module is for applying for a loan that is basically the same as the deposit menu item, except the prompt is different.

R0WvuIHL2K

Well, hot damn.

I've now got a menu building system that is closed for modification, but open for extension. To extend the functionality, all a developer needs to do is add a module to the package, with a class that inherits from BaseAction. Then it will magically appear in the menu UI.

YMMV

Many senior developers suffer from The Curse of Knowledge.

Being able to think about code like this, and then design the code in a way that is extensible is not an easy path. It requires years and years of tinkering, failing, little successes, and then starting all over again.

Once a developer obtains enough context, skills, and knowledge to be able to do this, they immediately forget how hard it was to obtain the knowledge and expect that you should be able to do it - regardless of your skill level.

I'm here to encourage you to not give up. Ignore the Cursed Ones. Keep working at it, and you'll gain the knowledge eventually. Just be patient.