Introduction
Every developer at some point has faced problems when it comes to implementing new features into legacy code. Your component needs to have another functionality implemented. What if the project that you are working on is getting bigger each day, how would you assure that creating new functionalities wouldn't be painful? By exploring a simple example we will understand what maintainable code really is.
PS.: I choose Python due to its simplicity, what will be shown can be easily applied in any other modern language.
Case Study 📝
So you are a developer building the back-end of a two numbers calculator. The most straightforward approach would be something like
def calculate(first_number, second_number, operation):
result = 0
if operation == "addition":
result = first_number + second_number
elif operation == "subtraction":
result = first_number - second_number
elif operation == "multiplication":
result = first_number * second_number
elif operation == "division":
result = first_number / second_number
return result
What if you need to implement another operation, like raising the power of a number? Would you create another elif
statement operation == "power"
? What if you had to implement more complicated operations?
There are definitely better ways of writing the back-end of this calculator, that is what we are going to dive into.
Refactoring the Code 💻
Whenever we think about large scale projects, it is always important to build components that can easily scale modifying as little as possible the existing code. Your new feature should be an extension of what is already there, not a restructure of it. That is what the 'O' of the S.O.L.I.D. principles stands for:
software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification
Our goal is not to explain SOLID Principles or Design Patterns, but providing a solution for the problem we've pointed. For the moment let's keep these principles under the hood...
1. Creating Enum
It is important to define in our system which operations our calculator will handle, we can do it by creating an Enum
.
from enum import Enum
class Operations(Enum):
ADDITION="addition"
SUBTRACTION="subtraction"
MULTIPLICATION="multiplication"
DIVISION="division"
As soon as we have our Enum
let's rewrite our function using the type we created
def calculate(first_number: float, second_number: float, operation: Operations) -> float:
result = 0
if operation == Operations.ADDITION:
result = first_number + second_number
elif operation == Operations.SUBTRACTION:
result = first_number - second_number
elif operation == Operations.MULTIPLICATION:
result = first_number * second_number
elif operation == Operations.DIVISION:
result = first_number / second_number
return result
We are no longer manipulating strings to define which operation has to be performed. From now on every operation is going to be strictly defined in the Enum
created.
2. Segregating Responsibilities
As we discussed earlier what if we want to implement more complicated operations? It does not seem like a good idea to let this function get bigger and bigger as we need to grow our project. So let's decouple the main calculator function from its functionalities
2.1. Operations
def addition(first_number: float, second_number: float) -> float:
return first_number + second_number
def subtraction(first_number: float, second_number: float) -> float:
return first_number - second_number
def multiplication(first_number: float, second_number: float) -> float:
return first_number * second_number
def division(first_number: float, second_number: float) -> float:
return first_number / second_number
2.2. Calculator
def calculate(first_number: float, second_number: float, operation: Operations) -> float:
if operation == Operations.ADDITION:
return addition(first_number, second_number)
if operation == Operations.SUBTRACTION:
return subtraction(first_number, second_number)
if operation == Operations.MULTIPLICATION:
return multiplication(first_number, second_number)
if operation == Operations.DIVISION:
return division(first_number, second_number)
raise ValueError(f"{Operations} value not supported => {operation}")
As we can see, now it is much easier to create and apply new operations to our calculator. We also took the opportunity to create error handling. If an invalid value for operation is inputted, an error will be raised so we can more easily track unexpected arguments so avoiding unwanted behaviors.
However, our initial proposal was building it as if it were made for large scale projects, so let's take a step further and improve this function...
3. Taking a Step Further
In our example we have simple mathematical operations to be implemented. Nevertheless if we were building features which depend on complex business logic, we should make our code more generic, so to speak. We must structure our components so they rely on interfaces, not on concrete implementations. This is also a SOLID principle, so it's highly recommended to know these concepts by heart.
3.1. Creating Classes for Implementations
Since we are taking into account complex implementations, let's create classes to our calculator operations; these classes will have a public method called calculate
. For the sake of simplicity only one example will be shown
class AdditionOperation(CalculatorOperationsInterface):
def __init__(self, first_number: float, second_number: float):
self.__first_number = first_number
self.__second_number = second_number
# Here you can create more methods to apply business logic
def calculate(self) -> float:
return self.__first_number + self.__second_number
One might be wondering what is CalculatorOperationsInterface
. That is the interface we are applying to all of our operations.
3.2. Creating Interface
Now we need an interface for all our operations to conform. This interface requires a method which returns a float
. That is how we create interfaces in Python
from abc import ABC, abstractmethod
class CalculatorOperationsInterface(ABC):
@abstractmethod
def calculate(init) -> float:
'''calculate operations'''
Every class that implements this interface must have the calculate
method.
3.3. Factory Pattern
In order to make it more generic, we need to have a component that will create the objects of the implementation classes. The component just mentioned is a Factory Design Pattern. This is how it is gonna work: depending on the operation - Enum
- we're going to provide, our Factory has to create an object in which we can call the method calculate
.
Since new Python versions 3.10+ have introduced Switch
statements, that is what we are going to use, however you are free to use if
statements too
class CalculatorOperationsFactory:
@staticmethod
def create_operation_object(
operation: Operations,
first_number: float,
second_number: float
) -> CalculatorOperationsInterface:
match operation:
case Operations.ADDITION:
return AdditionOperation(
first_number=first_number,
second_number=second_number
)
case Operations.SUBTRACTION:
return SubtrationOperation(
first_number=first_number,
second_number=second_number
)
case Operations.MULTIPLICATION:
return MultiplicationOperation(
first_number=first_number,
second_number=second_number
)
case Operations.DIVISION:
return DivisionOperation(
first_number=first_number,
second_number=second_number
)
case _: # default case
raise ValueError(f"Enter a valid {Operations} => '{operation}' was entered")
3.4. Refactored Calculator Function
Once we have created our components, we can present our new calculator function
def calculate(first_number: float, second_number: float, operation: Operations) -> float:
operation_object = CalculatorOperationsFactory.create_operation_object(
operation=operation,
first_number=first_number,
second_number=second_number,
)
return operation_object.calculate()
4. What We Have Built
Okay, you might be thinking that this whole structure for a simple calculator is kind of overkill, and you are not completely wrong. However, what we built is much more than a calculator. What we have accomplished is a maintainable solution to apply to large scale systems whenever we need to implement new features to an existing component, as long as it is possible to gather those input conditions inside an Enum
.
4.1. How to Create New Features?
Creating new features is as simple as:
1. Creating a new Enum
value
2. Creating the feature class implementing the interface created
3. Creating a new case
in Factory
As you can perceive almost no existing code is modified to implement new features. This is ideal when we are in a project in which dozens of developers are contributing to, it reduces the possibility of conflicts and makes it easier to maintain.
Top comments (0)