S.O.L.I.D. Design Principles in Python

Aserdargun
27 min readJan 7, 2024

--

  • Single Responsibility Principle
  • Open-Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

The S.O.L.I.D. principles are compiled by Robert C. Martin, also known as Uncle Bob.

Fragile code breaks in unexpected ways and rigid code cannot be changed.

We must achieve dependency firewalls. They prevent change in one module to propagate to other modules.

The S.O.L.I.D. principles also work on class or function levels.

Python is an object oriented language, but it is also a dynamic language. That means we do not need interfaces to support polymorphism. This can be an advantage, but sometimes it also makes explaining things harder.

The S.O.L.I.D. principles are all about quality control, developers use various principles to make their software manageable and keep it manageable in the future.

The goal of the S.O.L.I.D. principles is to structure our software in a way that allows us to extend, change and delete parts of our code without changing other parts of the code.

If you don’t have to change working code, you probably won’t break it.

Single Responsibility Principle

Things should have only one reason to change.

The goal here is to prevent a class from changing all the time for all sorts of reasons.

  • “TooManyThingsNameProblem”: Classes get big when they have many responsibilities. Naming the class is hard and finding code is hard
  • Mixing Responsibilities: We will look at an example of an employee class that mixes responsibilities and find out it is dangerous to change code around unrelated other code.
  • Dependencies on libraries: A harmless dependency in one class starts to invade another unrelated class.
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary

def raise_salary(self, factor):
return self.salary * factor

def save_as_xml(self):
pass

def print_employee_report(self):
pass

Until here, we all consider this to be business logic. But the class can also save an employee as XML and print a report of the employee.

The name of the class implies an employee entity, but the class does more than that. It also handles storage and reporting. You would not expect this by just looking at the class name.

The class name should tell us what it is responsible for.

If you see big class names like “EmployeeAndStorageAndReporting”, it might be the first clue that the class is doing too many different things. As you see, naming is hard, but also finding code in such classes is hard.

Clearly, the employee class has mixed responsibilities. In example, we removed print_employee_report method in the previous class. Employee now has two responsibilities: Business logic and storage logic.

class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary

def raise_salary(self, factor):
return self.salary * factor

def save_as_xml(self):
with open("emp.xml", "w") as file:
file.write(f"<xml><name>{self.name}</name><salary>{self.salary}</salary></xml>")

After implementing the save_as_xml method. IT opens the file emp.xml and writes XML to it. The file name is hard coded.

class Employee:
xml_filename = "emp.xml"

def __init__(self, name, salary):
self.name = name
self.salary = salary

def raise_salary(self, factor):
return self.salary * factor

def save_as_xml(self):
with open(self.xml_filename, "w") as file:
file.write(f"<xml><name>{self.name}</name><salary>{self.salary}</salary></xml>")

To make it more flexible for users of the class, the file name will be defined as a class variable at the top of the class.

Notice how the code for the two responsibilities is now scattered all over the class. There is a new subtle problem. The class variable with the file name is only used by the storage part. User of this class start reading the code from the top and asked themselves, why does this class know anything about XML file names?

The user now has to scan the code to find out it has nothing to do with business logic, but with the later added storage responsibility. This problem will get worse when we change the class again.

At this point, the employee can be saved as XML, nut now we want to replace XML with JSON.

The first thing we do is remove tha save_as_xml function.

class Employee:
xml_filename = "emp.xml"

def __init__(self, name, salary):
self.name = name
self.salary = salary

def raise_salary(self, factor):
return self.salary * factor

We forgot the class variable xml_filename. Imagine someone looking at this code in six months. Will this person be brave enough to delete the variable? You see that when responsibilities are mixed, it is not only harder to add new code, but you can also not safely delete code and be sure you have not forgotten something.

But if we add the save_as_json method, it will cause the same problems as before.

Clearly, we are violating the single responsibility principle.

We know that the class is doing too many things and we need to split it up in multiple classes.

First, we identify the responsibilities.

The employee class contains business logic

and storage logic. The storage logic will be moved to its own class.

When classes are split up, new problems are introduced: Missing dependencies. The save_as_json method is now in the EmplyeeStorage class.

How does it get access to an employee object? We choose simplest solution. We pass it when we call the save_as_json function.

class Employee:
json_filename = "emp.json"

def save_as_json(self):
with open(self.json_filename, "w") as file:
file.write(f"name: {self.name}, salary: {self.salary}")

One thing is immediately clear. Every line of code in this class has something to do with employee storage.

class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary

def raise_salary(self, factor):
return self.salary * factor

The employee class is stripped of storage logic and only contains business logic.

And how do we connect both classes?

We do this in main. main imports classes. An employee object is created, followed by an employee storage object. And finally the save_as_json method is called and an employee object is passed to the function.

Here is a dependency diagram of the program we just created. As we said before, OO allows us to manage our dependencies and the S.O.L.I.D. principles use this kind of dependency management.

At this point, we have achieved single responsibility in both classes.

But we have to be careful because we can violate the principle again very easy.

Sometimes we violate the single responsibility principle in very subtle ways.

The method constructs the JSON manually, and this is error prone. You found a library online that serializes and saves JSON in a single line of code.

All we need to do is import jsonlibrary. We run the program and get an error.

The JSON library tries to serialize the employee object. To do this, it searches for serializable attributes. But we have not marked name and salary as serializable. The error shows that all we need to do is to decorate the attributes with @jsonseralizable

We add the two decorators and a question rises. Where do the decorators come from? The answer is that they come from the JSON library. That means we have to import jsonlibrary in the employee class.

And this makes it very clear. Our goal was to get rid of all storage functionality in the employee class and suddenly the employee is polluted with knowledge about JSON serialisation.

Think about the complications here, it becomes impossible to move the employee entity to another project without installing the JSON library here. And whenever JSON serialisation becomes obsolete, we need to make changes to the employee class, which should be un aware of all things related to JSON.

There is only one way to solve it without breaking the single responsibility principle.

On the left, you see the employee class that should not know anything about JSON. On the right, there is the employee storage class that needs a serializable employee object. The boundary in the middle separates the entities from the storage logic. A new class has created: JSONEmployee. It is also hosted in the storage module. ITs only purpose is to provide a copy of the employee data attributes and mark them JSON serializable.

Each data attribute is copied from the employee object to the JSONEmployee object. Then the JSONEmployee object is passed to the save_as_json method. Something happens in de-serializing. Many projects where colleagues protested against this extra object in the middle. Because it seems to be a lot of extra work when all the data attributes for a lot of objects needs to be copied. But if you want to respect the single responsibility principle, the employee object must not know about JSON.

There are more examples of violations like this. You’ll find them in object relational mapper frameworks. In such frameworks, entities get multiple responsibilities.

Conclusion. When the single responsibility principle is violated, your classes get bigger and it gets more risky to change code without affecting other code. Sometimes more subtle violations happen, like in the decorator examples. Keep an eye on the import statements in your classes, they can provide clues for these kinds of violations.

Open Closed Principle

Open for extension but closed for modification.

How can a program be extended if you cannot change it? Surely you have to modify code to add ne functionality. Yes, but if you stick to this principle, the changes do not explode into all directions.

This is a selection that switches on object types. The reason to use the open-closed principle is easy. It prevents switches like this.

  • Selecting on types: First, We will see how a selection on types violates the open closed principle and how to solve it.
  • Extend functionality: Then we see how easy it is to add new functionality when we adhere to the principle.
  • Remove functionality: Finally, We will remove existing functionality to show how we can do it with the least amount of risk.
class Employee:
def __init__(self, name):
self.name = name

Here is a base class called Employee, it stores an employee name.

class Employee:
def __init__(self, name):
self.name = name




def print_employee(e):
print(f"{e.name} is an employee")

Look at the print_employee function, it takes a reference to an object of type employee. It prints the name of the employee.

Now the system needs to be extended with a manager class.

A manager inherits from employee and adds an attribute: department

class Employee:
def __init__(self, name):
self.name = name

class Manager(Employee):
def __init__(self, name, department):
super().__init__(name)
self.department = department

def print_employee(e):
print(f"{e.name} is an employee")

Here is the code that adds the manager class.

Manager inherits from employee and adds an extra attribute to the object: department. We overload the initializer to support the department attribute.

class Employee:
def __init__(self, name):
self.name = name

class Manager(Employee):
def __init__(self, name, department):
super().__init__(name)
self.department = department

def print_employee(e):
?

At this point, e can be an object of type employee or manager. We know that e has a name, but we don’t know if e has a department.

class Employee:
def __init__(self, name):
self.name = name

class Manager(Employee):
def __init__(self, name, department):
super().__init__(name)
self.department = department

def print_employee(e):
if type(e) is Employee:
print(f"{e.name} is an employee")
elif type(e) is Manager:
print(f"{e.name} leads department {e.department}")

The print_employee function needs a switch to check the type of e. In order to check for the type, the switch needs to know all possible employee types.

from employees import Employee
from employees import Manager

def print_employee(e):
if type(e) is Employee:
print(f"{e.name} is an employee")
elif type(e) is Manager:
print(f"{e.name} leads department {e.department}")

When the print_employee function is moved to a different module, this problem becomes even more visible. because to know every employee type, they need to be imported.

And this switch can be multiple places. Every time a new employee subclass is created, you have to change the code in all those places. It now becomes very clear that we cannot extend the functionality without changing existing working code. And there is the risk of changing the code in one place, but forget in another place.

This example is a violation of the open-closed principle.

The good news is that it is a solvable problem. And the technique to solve the problem is polymorphism. The employee will get a new method called get_info that returns employee information.

The manager will override this method to return manager information.

class Employee:
def __init__(self, name):
self.name = name

def get_info(self):
return f"{self.name} is an employee"

class Manager(Employee):
def __init__(self, name, department):
super().__init__(name)
self.department = department

def get_info(self):
return f"{self.name} leads department {self.department}"

def print_employee(e):
print(e.get_info())

This is how the code looks like. get_info in the employee class returns the employee name. get_info in the manager class returns the name plus department. The print_employee function does not have to know anymore what type e is. All it needs to do is to make a polymorphic call to get_info, to get the proper employee information.

We just saw how switching on object types violates the open closed principle.We also saw how to solve the problem with polymorphism.

At this point, extending functionality becomes very easy.

Let me add a new employee type to the system. The employee type is a programmer. It inherits from employee and adds an extra attribute called programming_language.

class Programmer(Employee):
def __init__(self, name, programming_language):
super().__init__(name)
self.programming_language = programming_language

def get_info(self):
return f"{self.name} programs in {self.programming_language}"

Since we adhere to the open closed principle, all we need to do is to add a new class that overloads the get_info method properly.

And instead of having to add an extra case to check if the type of e is programmer, we did not have to change to print_employee function at all. And that is what we call open for extension, closed for modification.

Another huge benefit of working with the open-closed principle is that removing functionality can be done relatively safe.

Let’s say that the company finally realized it does not really need managers and asks you to remove everything that has something to do with manager objects.

You don’t have to think about this too long. Since there are no switches in the code, that check for the manager object type, you can just delete the manager class. Perhaps you will have to clean up some places where managers were instantiated in the code, but that is as easy as running the code and see what errors occur when the code is compiled.

Whenever you see if-else statements or switches that select on object types, you might be looking at a violation of the open-closed principle.

Liskov Substitution Principle

Subclasses should not change the behavior of super classes in unexpected ways.

This principle is closely relate to the open-closed principle. Violations of the Liskov substitution principle usually show us where the open-closed principle is violated.

The technical description of the Liskov substitution principle goes something like this. If B is a subtype of A, objects of type A may be replaced with objects of type B without breaking things. Sounds great, but what does it mean?

We will see that by deliberately violate the principle and break the design of the code.

Before this, I already want to tell you how to adhere to the principle. Again, we use polymorphism. You might wonder, isn’t there anything polymorphism cannot do for us? Well, the answer is yes.

Liskov substitution violations sometimes also indicate design flaws, and here polymorphism won’t save us. We just have to redesign parts of the code. We will se four ways to violate the Liskov substitution principle.

  • Selecting on types
  • Break the is-a relationship
  • Raise error in overridden method
  • Break constraints

Selection on types: It is one of the most common violations of the principle. A selection on types. We have already seen this problem in the last chapter on the open-closed principle.

There is a base class. And a subclass that implements extra attributes. A function that takes a reference to an employee object now has to check what type of object e is. In order to check for the type, the switch must know all possible employee types, and so it violates the open-closed principle.

And since super type employee cannot be replaced by subtype manager without checking the type of e, the Liskov substitution principle is also violated.

Break the is-a relationship: This is more subtle. When classes inherit from each other, we say they have an is-a relationship. For this example, we’ll create an employee class and inherit an intern class from it. We assume an intern is an employee.

We start with an employee class that stores a name and salary. We create a method that prints the name and year salary. Finally, I create an employee and call the method print_year_salary.

The system should also support interns. In our system, interns are employees without salaries. Since the intern has no salary at all, the salary should not be zero, but set to None.

class Intern(Employee):
def __init__(self, name, salary):
super().__init__(name, None)

This is done in the initializer. The initializer is overridden and passes None to the super initializer. That way, the salary will always be none, no matter what salary the intern is instantiated with. This leads to a problem.

When the year salary for an intern is printed, an error occurs because although it looks like Chuck’s salary is zero, the intern calls overwrites to value with None.

The intern class changes the behavior of the superclass in unexpected ways by setting the salary to None. Perhaps you think, why not set the value to zero? That way the code will not crash. That’s true, but that would create another problem.

For example, interns would lower the average salary of all employees, even if they don’t have a salary at all. This solution here is simple, even if an intern is an employee, for all normal intents and purposes. An intern object is not an employee object because the behavior is not consistent.

The print_year_salary function would have to check the employee type to work properly, but that would violate the open-closed principle.

This is an example where polymorphism cannot help us, and the conclusion is that the intern class should not be inherited from the employee class. An intern object does not have an is-a relationship with an employee object.

You just saw that breaking the is-a relationship can violate the Liskov substitution principle. There is another example that breaks the is-a relationship.

Raise error in overridden method: It is caused when overridden methods raise errors that are not inherited from errors raised by the parent class. A common example is a not implemented error.

An employee can be promoted by calling the promote method.

Class intern inherits from employee, but an intern cannot be promoted. The promotion effort is overridden and raises NotImplementedError.

The promote_employee function takes an employee and calls promote on it. The same problem as in the previous example occurs. An intern object is not an employee object because the behavior is not consistent.

The promote_employee function would have to check the type of e to know if it should call the promote.

The overridden promote method raises an error that is never raised by the method it overrides. This is a violation of the Liskov substitution principle.

Break constraints: The last violation I’m going to show is ignoring class constraints. An example of this violation is when subclasses change values without checking constraints.

Class employee takes an employee_id and name. Both parameters are stored in the object. The employee class has a method to check if the employee_id is valid. The employee_id must be a number greater than zero. Class Intern is added to the system and overrides the initializer. For now, it just calls the initializer of the base class. An intern object is created and the system checks if the object is valid. The result is true.

Now, the personnel department wants to see a list of employees and asks if the employee IDs of the interns can be prefixed with the letter I. This would ask for a change in the reporting module, but time is short and the developer creates a hack.

The developer prefixes the letter I when the initializer of the superclass is called. This will result in the desired report, but it also violates the employee ID constraint.

The constraint specified that the employee_id must be a valid number, greater than zero.

When the is_employee_id_valid method is called, it will return false. The subclass has violated the constraint.

Conclusion, it doesn’t matter how tiny the change of behavior in a subclass is, as soon as the subclass starts to change the behavior in unexpected ways, the subclass does not have an is-a relationship with its superclass anymore. From our examples with the intern subclass, there is a simple conclusion to draw.

An intern object is not an employee object, according to our system. Liskov substitution principle violations can be difficult to detect and even harder to solve when the system is already in use. If we do not spot them early on, at one point, the only practical solution is to implement if-else statements and thus break to open-closed principle. Sometimes it would be better just to accept right from the start that some classes do not have an is-a relationship.

Interface Segregation Principle

This chapter is all about keeping interfaces cohesive.

You know what the purpose of an interface is, an interface defines a signature that must be implemented by classes that implement that interface. That same interface can now be used as a parameter type for constructors or properties of other classes. That is the magic behind dependency inversion that we will look at in the next chapter. Interface? But Python does not have interfaces.

We can create base classes or abstract base classes and use multiple inheritance to inherit from them. But in a dynamic language like Python, an interface does not have to be a physical thing to all. An interface exists at the moment when we specify it in our designs.

For example, look at the class diagrams on the screen. Both classes have a method raise_salary that seems public. Can you guess what the common interface for both classes is?

Here it is. The task of an interface is to describe the signature of the class. Now we know what an interface is. So what is the interface segregation principle?

no client should be forced to depend on methods it does not use.

This sounds plausible, and yet it is very easy to violate the principle. Let met give you an example of such violation.

We are going to write a class for an iPhone, our iPhone can make phone calls and can be unlocked by swiping.

You can see method to make a phone call and a method to unlock the phone. If we would extract an interface from this class, it would look something like this.

interface from this class, it would look something like this.

At this point, you could create a bunch of smart phones that all implement the phone interface.

But now we want to create a class for a Nokia 2720. This is a feature phone without a touchscreen.

When we implement the code for this phone, we see a problem.

What does this phone do when swipe_to_unlock is called? Right now, it would raise an error and the problem is that there is not much we can do to improve the situation.

The best thing we can do is override the method and add some information to the error, but the reality is that this method should not be known at all in the Nokia class. Clients of the class would always have to check if the phone is a Nokia to prevent calling swipe_to_unlock. As you have seen in Chapter three, this violates the Liskov substitution principle, but why does it also violate the interface segregation principle?

The phone interface has two roles, one role has something to do with making phone calls and the other has something to do with touch screens, but both roles are combined in a single interface. The interface forces a phone without a touch screen to deal with touch functionality. That interface is not coherent. This is a violation of the interface segregation principle. The solution is to break up the interface by its roles.

Here is the old interface called Phone. I’m going to split it in two interfaces.

One interface for phone calls and one for touch functionality.

Now, the iPhone class can use multiple inheritance to inherit from both interfaces.

And the Nokia class only inherits from the phonecall interface. The swipe_to_unlock method is now fully.

We have successfully broken up the single incohesive interface into two cohesive interfaces that are grouped by their rules and do not violate the interface segregation principle anymore. So what happens when new functions are added to the phone classes?

Emergency call, Before we started with interface segregation, we would have asked ourselves this question: what phones can make an emergency call?

Here is another example, text messages.

We could put it into phonecall interface, but that would add another role to the phonecall interface. The solution is clear, we need to introduce a third interface.

And this is how interface segregation works, we break up the interface in groups of functions. This prevents subclasses from being polluted by methods they do not use.

Conclusion, Whenever clients depend on so-called ‘fat interfaces’, but only use few methods from it, you might be looking at a violation of the interface segregation principle. The goal is to create cohesive interfaces by breaking up in groups of functions.

Watch out that breaking up interfaces does not become the goal itself. It is possible to overdo interface segregation. Finding a good balance is key here. You have seen the first four principles of solid.

We started with the single responsibility principle that states that things should only have one reason to change.

We continued with the open-closed principle while we saw it is indeed possible to extend software with a minimal amount of changes in existing code.

The Liskov substitution principle showed how important it is that subclasses do not change the behavior of super classes in unexpected ways.

And we just have seen how to break up interfaces to adhere to the interface segregation principle. If I would have to pick two favorite principles, it would be the open-closed principle and the one we are going to look at next.

Dependency Inversion Principle

This is the final topic of this course, it provides the mechanism behind most other principles.

High level modules should not depend on low level modules. Instead, they should depend on abstractions.

This can be difficult to grasp.

Your program starts in main. Main calls function f in module M. M calls function g in module N. Is this an object oriented design? The answer is we don’t know. It could be anything, it could be a procedural design. It could be OO. All we know is that high level modules call functions in lower level modules. The higher level modules depend on the lower level modules.

In order for main to use module M, main needs to import M. In order for module M to use module N, M needs to import N. We call this a source code dependency. High level modules know about the lower level modules. They target concrete classes. This design comes with tow problems.

  • Concrete classes are volatile.
  • Depending on concrete classes breaks the open-closed principle

What does the first problem mean? Concrete things change a lot. Abstract things change much less frequently.

If high level modules depend on low level modules, changes in low level modules trigger a recompile in all higher level modules. Historically, this was a problem because of compile times. In 2021, this is not a big problem anymore.

The second problem is more serious. So where do things start to go wrong?

Imagine the software for a cash register. Each time a purchase is made, a receipt is printed.

We create a class called Reporting. The class contains a method called print_receipt that takes a string with the receipt text. At this point, we need to send the text to a printer. We will use the internal cash register printer for this. Lets create a class for it.

A module printers.py is created and has a class CashRegisterPrinter. How do we use the class?

The reporting module needs to import from the printers module. Then it instantiates a new CashRegisterPrinter. This code will work, but we have created a dependency on a concrete class. This is a violation of the dependency inversion principle. And by doing so, it also violates the open-closed principle.

Let me demonstrate this by adding a new printer. Instead of printing to the internal cash register printer, a laser printer will be used. Let’s create a class for the laser printer in printers.py

printers.py now has two printer classes. One for the internal cash register printer and one for the laser printer. We have extended the software but need to modify existing code to use it.

In order to change the printer, the reporting module needs to be changed. This is a violation of the open-closed principle. This time it is caused because we depend on concrete classes instead of on abstractions. So how do we solve the problem? Let me show a dependency diagram of the current code.

Main imports reporting. Reporting imports LaserPrinter. Now you are going to see what inversion means in dependency inversion.

The first thing we do is to introduce a printer interface. This interface will just exist in our design. In a dynamic language like Python, we do not physically have to implement interfaces to get polymorphism.

LaserPrinter inherits the printer interface. And the reporting module depends on the printer interface.

All arrow used to point downwards. Modules depended on lower level modules. Bu now Reporting depends on an interface, it depends on something abstract. LaserPrinter depends on that same abstraction. This is a break in the dependency chain. It does not matter what happens in LaserPRinter. The reporting module doesn’t even know there is such a thing as a laser printer. All it knows is that there is something with a method called print_recipt. So how do we hook this up in Python?

Let’s start from the top and implement main. The first thing you notice is that all the dependencies are imported here. A LaserPrinter object is instantiated. And then a Reporting object. And this is the place where the printer needs to be injected. To do so, the reporting class gets a class initializer that allows for the printer object to be injected.

A dunder init method is added and the printer argument is stored in self. self.printer now holds an instance of a printer object. This printer object can now be used to print the receipt. Notice there are no imports and no object instantiation is happening here. There is no dependency on a concrete class.

We can now pass the printer object to the reporting class. Finally, print_receipt is called on the reporting object. We have now solved the dependency inversion. And this is the point where we start to benefit when the requirements change.

For example, what happens if we switch back to the internal cash register printer?

We swap the laser printer for a cash register printer in main. The reporting class does not need to be modified anymore to support another printer.

Perhaps you are wondering what we have gained here. We still have to change code to support a new printer. The open-closed principle does not prevent code modification, but it confines it to specific places. Main is an excellent place to create the dependency tree that will be used in the rest of the program.

If you are worried about an explosion of import statements in main, there are techniques to help preventing that. You can use factories. A factory is an object that creates new objects. That way you can move the logic out of main and into a factory.

Some people like to use IOC container frameworks to manage the dependencies for them. The dependency tree is configured in a config file and resolved by the framework when needed.

Personally, I favor writing out the dependency tree in source code. This gives me full control over the life span of each and every object in the code. As always, there is a solution for each scenario and you have to find out what works best for you.

Conclusion, The dependency inversion principle is the mechanism behind many of the other principles. Especially the open-closed principle. It allows us to depend on interfaces rather than upon concrete classes. The symptoms of violations of the dependency inversion principle are object instantiation in lower level modules and the import statements needed for these instantiations. These violations can be solved by injecting dependencies.

Conclusion

  • First of all, you have seen that the S.O.L.I.D. design principles are closely related to each other.
  • Although they describe different symptoms, violation of the principles always lead to the same problem when extending the system. Having to change the code in undesired or unexpected places.
  • When software requirements change, the solid principles can protect you.
  • This is important because software developers know that code always grows and code always changes. So we better protect ourselves from requirement changes.
  • You saw that the tools that are used by the principals are very similar. A bit of separation here, some polymorphism there, and everything is fine again. However, checking the principles in our software must be done constantly. Every time the system changes it is easy to violate the principles.
  • Patterns and principles are things that are not invented but discovered by developers. Usually that happens after repeating things. The S.O.L.I.D. principles will start to make sense.

Resource

https://www.udemy.com/course/solid-design-principles-with-python

--

--

Aserdargun

Innovator, Problem Solver, Python Geek, Data Scientist , Mechanical and Industrial Engineer, Lean Practitioner, Lean 6 Sigma B.Belt