Design Patterns Every Software Engineer Should Know

Muralikrishnan Rajendran
CognitiveCraftsman
Published in
12 min readDec 21, 2023
Source: Image by the Author

What are Design Patterns?

Design patterns in software engineering are typical solutions to common problems in software design. They represent best practices, evolved over time, and are a toolkit for software developers to solve common problems efficiently.

For example, the Singleton pattern ensures that a class has only one instance and provides a global point of access to it, optimizing resource usage and consistency.

class Singleton:
_instance = None

def __new__(cls):
if cls._instance is None:
print("Creating the instance")
cls._instance = super(Singleton, cls).__new__(cls)
return cls._instance

# Usage:
singleton1 = Singleton()
print("Instance 1:", singleton1)

singleton2 = Singleton()
print("Instance 2:", singleton2)

# Both instances will be the same
assert singleton1 is singleton2

In this example, the `Singleton` class overrides the `__new__` method, which is responsible for creating a new instance of the class. The first time the `Singleton` class is instantiated, it creates a new instance. On subsequent instantiations, it returns the already created instance. This ensures that there’s only one instance of the `Singleton` class at any given time, thereby adhering to the Singleton pattern.

Why Design Patterns Matter?

  1. Solving Recurring Problems: Design patterns offer solutions to problems that software developers encounter repeatedly. Instead of reinventing the wheel for each project, engineers can use these patterns as blueprints for solving common design issues.
  2. Facilitating Communication: Design patterns provide a shared language for software developers. When a developer mentions a specific pattern like ‘Singleton’ or ‘Observer,’ their peers immediately understand the design concept being discussed. This shared vocabulary facilitates clearer and more efficient communication among team members.
  3. Enhancing Code Reusability and Maintainability: By using established patterns, developers create code that is more modular and easier to maintain. Patterns encourage the principle of ‘Don’t Repeat Yourself’ (DRY), which leads to less redundant code and easier updates and maintenance.
  4. Improving Scalability: Design patterns often incorporate principles that make it easier to scale applications. For instance, patterns that emphasize modularity and loose coupling allow systems to grow without significant rework.
  5. Facilitating Best Practices: Design patterns encapsulate best practices derived from experienced developers and designers. They guide less experienced developers in using methods that have proven effective over time.

Consider an application that requires different types of notifications to be sent out, such as email, SMS, and push notifications. Implementing this without a design pattern might lead to a complex if-else or switch-case block that selects and creates the appropriate notification type. This approach, however, is not scalable and violates the Open-Closed Principle (software entities should be open for extension but closed for modification). We could apply — “Factory Method Pattern” to solve the above problem. The Factory Method Pattern defines an interface for creating an object but allows subclasses to alter the type of objects that will be created.

    class Notification:
def send(self):
pass

class EmailNotification(Notification):
def send(self):
return "Sending Email Notification"

class SMSNotification(Notification):
def send(self):
return "Sending SMS Notification"

class NotificationFactory:
@staticmethod
def create_notification(type):
if type == 'email':
return EmailNotification()
elif type == 'sms':
return SMSNotification()
else:
raise ValueError('Notification type not supported')

# Client Code
notification_type = 'email' # This can be dynamically determined
notification = NotificationFactory.create_notification(notification_type)
print(notification.send())

In this example, the `NotificationFactory` class encapsulates the logic for creating different types of notification objects. This makes the code more modular, easier to maintain, and extend. For instance, adding a new notification type like ‘PushNotification’ would only require adding a new subclass and updating the factory method, without modifying the existing client code.

Are Design Patterns Still Relevant?

Despite the evolution of technology and the emergence of new programming paradigms, the core problems that design patterns address, such as modularity, maintainability, and code reusability, remain. While the specific implementations of these patterns might evolve with new technologies, the underlying principles continue to be relevant. For instance, in microservices architecture, patterns like Proxy, Circuit Breaker, or API Gateway are crucial for handling distributed system concerns.

Design Principles

The four key principles central to design patterns — encapsulation, abstraction, inheritance, and polymorphism — are foundational concepts in object-oriented programming (OOP). Each plays a crucial role in creating efficient, reusable, and maintainable software. Let’s explore each principle with examples:

1. Encapsulation:

Encapsulation is the bundling of data and methods that operate on that data within one unit, typically a class in OOP. It restricts direct access to some of an object’s components, which is a means of preventing accidental interference and misuse of the methods and data.

Example: Consider a class `BankAccount`. Encapsulation allows us to hide the account balance from direct access, exposing only methods to `deposit` and `withdraw`.

class BankAccount:
def __init__(self):
self.__balance = 0 # private variable

def deposit(self, amount):
if amount > 0:
self.__balance += amount
return self.__balance

def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount
return self.__balance

# Usage
account = BankAccount()
account.deposit(100)
print(account.withdraw(50)) # Output: 50

In this example, `__balance` is a private attribute. It cannot be accessed directly from outside the class, which prevents unauthorized operations on the account balance.

2. Abstraction

Abstraction involves hiding complex implementation details and showing only the necessary features of the object. It helps in reducing programming complexity and effort.

Example: Imagine a class `Car` that exposes a method `drive`. The `drive` method abstracts away the complexities of the internal workings of the car, like fuel injection, gear shifting, etc.

class Car:
def drive(self):
self.start_engine()
self.change_gears()
# More complex steps
print("Car is moving")

def start_engine(self):
# Complex engine start procedures
pass

def change_gears(self):
# Complex gear changing procedures
pass

# Usage
my_car = Car()
my_car.drive() # Output: Car is moving

Users of the `Car` class need not understand how the engine starts or gears change; they just need to know how to call the `drive` method.

3. Inheritance

Inheritance allows a class to inherit properties and methods from another class. It promotes code reuse and establishes a relationship between classes (parent and child classes).

Example: Let’s say we have a parent class `Vehicle` and two child classes `Car` and `Motorcycle`. Both child classes inherit common properties from `Vehicle`.

class Vehicle:
def has_wheels(self):
return True

class Car(Vehicle):
def number_of_wheels(self):
return 4

class Motorcycle(Vehicle):
def number_of_wheels(self):
return 2

# Usage
car = Car()
motorcycle = Motorcycle()
print(car.has_wheels()) # Output: True
print(motorcycle.number_of_wheels()) # Output: 2

Both `Car` and `Motorcycle` inherit the `has_wheels` method from `Vehicle`.

4. Polymorphism

Polymorphism allows objects of different classes to be treated as objects of a common superclass. It is the ability to present the same interface for differing underlying forms (data types).

Example: Using the previous example of `Vehicle`, `Car`, and `Motorcycle`, we can demonstrate polymorphism by writing a function that operates on any `Vehicle` object.

def vehicle_info(vehicle):
print("Has wheels:", vehicle.has_wheels())
print("Number of wheels:", vehicle.number_of_wheels())

vehicle_info(car) # Works with a Car object
vehicle_info(motorcycle) # Works with a Motorcycle object

Here, `vehicle_info` function can work with any object of `Vehicle` or its subclasses, demonstrating polymorphism

“Each of these principles plays a vital role in effective software design, contributing to the development of systems that are more robust, flexible, and maintainable. By adhering to these principles, developers can create software that effectively addresses the needs of users while remaining adaptable to changing requirements.”

Categories of Design Patterns

Design patterns are broadly categorized into three types:

  • Creational Patterns: Simplify object creation. Example: Singleton Pattern ensures a class has only one instance and provides a global point of access to it, used in database connections.
  • Structural Patterns: Deal with object composition. Example: Adapter Pattern allows incompatible interfaces to work together, used in system integrations.
  • Behavioral Patterns: Handle communication between objects. Example: Observer Pattern, where an object, called the subject, maintains a list of its dependents, called observers, and notifies them automatically of any state changes.

Best Design Patterns to Use, with Examples

Strategy Pattern:

The Strategy Pattern falls under the category of Behavioral Design Patterns. It defines a family of algorithms, encapsulates each one, and makes them interchangeable. Used in sorting algorithms where the sorting strategy can be switched dynamically.

Example: Consider a context where we need different sorting strategies for a list. The Strategy pattern allows us to change the sorting behavior without altering the client’s code.

from typing import List

# Strategy Interface
class SortingStrategy:
def sort(self, data: List[int]) -> List[int]:
pass

# Concrete Strategies
class BubbleSortStrategy(SortingStrategy):
def sort(self, data: List[int]) -> List[int]:
# Implement bubble sort
return sorted(data) # Simplification for example purposes

class QuickSortStrategy(SortingStrategy):
def sort(self, data: List[int]) -> List[int]:
# Implement quick sort
return sorted(data) # Simplification for example purposes

# Context
class SortedList:
def __init__(self, strategy: SortingStrategy):
self.strategy = strategy
self.data = []

def add(self, value: int):
self.data.append(value)

def sort(self):
return self.strategy.sort(self.data)

# Usage
data = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]
sorted_list = SortedList(BubbleSortStrategy())
sorted_list.data = data
print("Bubble Sort:", sorted_list.sort())

sorted_list.strategy = QuickSortStrategy()
print("Quick Sort:", sorted_list.sort())

In this example, we can switch between different sorting algorithms (bubble sort, quick sort, etc.) by changing the strategy object associated with `SortedList`. This makes sorting algorithms interchangeable and easily extendable.

Composite Pattern:

The Composite Pattern falls under the category of Structural Design Patterns. It allows you to compose objects into tree structures to represent part-whole hierarchies. Used in graphical user interfaces to represent and work with hierarchies of objects.

Example: Imagine a graphic design application where graphic elements can be composed into complex structures like groups, but each element, including groups, can be treated as an individual object.

# Component
class Graphic:
def render(self):
pass

# Leaf
class Circle(Graphic):
def render(self):
return "Rendering Circle"

# Leaf
class Square(Graphic):
def render(self):
return "Rendering Square"

# Composite
class CompositeGraphic(Graphic):
def __init__(self):
self.graphics = []

def render(self):
return "Composite Graphic [" + ", ".join(graphic.render() for graphic in self.graphics) + "]"

def add(self, graphic: Graphic):
self.graphics.append(graphic)

# Usage
circle = Circle()
square = Square()
composite = CompositeGraphic()

composite.add(circle)
composite.add(square)

print(circle.render()) # Individual element
print(composite.render()) # Composite of elements

In this example, both individual shapes (`Circle`, `Square`) and a composite of shapes (`CompositeGraphic`) can be treated uniformly. The `render` method works regardless of whether it’s called on a single object or a composition, demonstrating the Composite Pattern’s ability to simplify client code when dealing with tree-like structures.

“Both the Strategy and Composite Patterns provide a means to achieve flexibility and modularity in software design, demonstrating the power of using well-defined design patterns in software development.”

Comparison with Other Software Development Practices

Design patterns and software development methodologies like Agile or DevOps serve different but complementary roles in the software development process. To illustrate the distinction and the integration of design patterns with these methodologies, let’s consider an example scenario:

Scenario: Developing a Web Application

A software team is working on a new web application. The team decides to adopt the Agile methodology for project management and DevOps practices for continuous integration and deployment.

  • Agile Methodology in the Scenario: Agile is a project management methodology that emphasizes iterative development, customer feedback, and flexibility. It involves breaking the project into small, manageable pieces (sprints), with regular reassessments and adaptations. The team divides the web application development into two-week sprints. Each sprint focuses on delivering a potentially shippable product increment, like a login feature in one sprint and a user profile management feature in the next. The team regularly meets to review progress, get feedback, and plan subsequent sprints.
  • DevOps Practices in the Scenario: DevOps is a set of practices that combines software development (Dev) and IT operations (Ops), aiming to shorten the development lifecycle and provide continuous delivery with high software quality. The team sets up an automated pipeline for continuous integration and deployment. Every time a developer commits code, it triggers an automated build and test process. Successful builds are automatically deployed to a staging environment for further testing before being pushed to production.
  • Integration of “Design Patterns” in the Scenario: We could use Composite Pattern for the design of the user interface components. For example, creating a unified interface for individual UI elements (like buttons, text fields) and composite elements (like forms, panels). Strategy Pattern could be employed to implement different authentication strategies (like password-based, OTP-based) for the login feature. This allows the authentication strategy to be changed dynamically without impacting the rest of the login system.

In this scenario:

  • Agile guides the overall project management, focusing on how the team organizes work and responds to change.
  • DevOps enhances the efficiency of development and deployment processes, focusing on collaboration and automation in software delivery.
  • Design Patterns provide solutions to specific coding problems within the development process, enhancing code quality, maintainability, and scalability.

Integrating design patterns within an Agile and DevOps framework enhances the technical robustness of the project without conflicting with the project management or deployment strategies. This integration ensures that while the project management and deployment processes are streamlined and efficient, the codebase itself is also well-architected and maintainable.

Common Misconceptions and the Future of Design Patterns

“A common misconception is that design patterns are universally applicable. Overusing or inappropriately applying patterns can lead to overly complex, inefficient code. It’s vital to understand the problem at hand thoroughly before selecting a pattern. The future of design patterns is closely tied to the evolution of software development. With the rise of AI and machine learning, new patterns suited to these paradigms are emerging. Nonetheless, the fundamental principles they embody remain relevant.”

How to Get Started? Resources and Learning Tools

Getting started with design patterns in software engineering can be an enriching journey. The key is to begin with the right resources and progressively build your understanding and skills. Here’s a more comprehensive guide on how to get started, with an array of resources and learning tools:

1. Books

  • Design Patterns: Elements of Reusable Object-Oriented Software by Gamma, Helm, Johnson, and Vlissides: Known as the “Gang of Four” book, this is the seminal work in the field. It provides an in-depth explanation of 23 classic design patterns and is considered a must-read for understanding the fundamentals.
  • Head First Design Patterns by Eric Freeman and Elisabeth Freeman: This book offers a more approachable and engaging introduction to design patterns. It’s particularly well-suited for beginners, offering a hands-on approach with a focus on practical implementation.
  • Patterns of Enterprise Application Architecture by Martin Fowler: For those interested in enterprise-level applications, Fowler’s book provides a comprehensive guide to design patterns applicable in large-scale systems.

2. Online Courses

  • Coursera: Offers courses like “Design Patterns” by University of Alberta, which covers design patterns systematically and is suitable for those who prefer structured learning.
  • Udemy: Provides a variety of courses tailored to different levels of experience. Courses like “Design Patterns in C# and .NET” are popular for their practical approach.

3. Interactive Learning Platforms

  • Refactoring.Guru: An excellent online resource that offers an interactive approach to learning design patterns. It includes clear explanations, UML diagrams, and real-world examples.
  • Codecademy: Provides interactive coding challenges that are excellent for beginners to get hands-on experience as they learn.

Remember, mastering design patterns is a journey. Start with fundamental concepts, gradually move to more complex patterns, and always try to apply what you’ve learned in practical scenarios. This approach will solidify your understanding and enhance your software design skills.

Conclusion

Understanding and skillfully applying design patterns is akin to an architect meticulously choosing the right blueprint for a skyscraper. Just as the strength and beauty of a building lie in its design, the quality, maintainability, and scalability of software are deeply rooted in the application of these patterns. They are the silent heroes in the software’s architecture, ensuring that systems are not only functional but are also adaptable and future-proof.

“In your journey as a software engineer, embracing design patterns is not just enhancing your toolkit; it’s about embracing a mindset of strategic problem-solving and innovation. With each pattern you master, you unlock a new level of proficiency, enabling you to navigate the complex world of software development with confidence and creativity.”

Moreover, in an industry where change is the only constant, design patterns provide a sense of continuity and reliability. They are the wisdom passed down from generations of developers, distilled into principles that withstand the ebbs and flows of technology trends.

So, as you step forward in your career, remember that mastering design patterns is more than a professional requirement; it’s a commitment to excellence, a path to becoming a visionary software craftsman. It’s an invitation to be part of an elite group of developers who don’t just write code but sculpt it with the finesse of an artist and the precision of an engineer.

References:

Freeman, E., & Freeman, E. (2004). Head First Design Patterns. O’Reilly Media.

Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley.

Fowler, M. (2002). Patterns of Enterprise Application Architecture. Addison-Wesley.

Beck, K., & Cunningham, W. (1987). Using pattern languages for object-oriented programs. In OOPSLA-87 workshop on Specification and Design for Object-Oriented Programming.

Martin, R. C. (2000). Design Principles and Design Patterns. Object Mentor.

Alur, D., Crupi, J., & Malks, D. (2003). Core J2EE Patterns: Best Practices and Design Strategies (2nd ed.). Prentice Hall.

Disclaimer

The data and the content furnished here are thoroughly researched by the author from multiple sources before publishing and the author certifies the accuracy of the article. The opinions presented in this article belong to the writer, which may not represent the policy or stance of any mentioned organization, company or individual. In this article you have the option to navigate to websites that’re not, within the authors control. Please note that we do not have any authority, over the nature, content, and accessibility of those sites. The presence of any hyperlinks does not necessarily indicate a recommendation or endorsement of the opinions presented on those sites.

About the Author

Murali is a Senior Engineering Manager with over 14 years of experience in Engineering, Data Science, and Product Development, and over 5+ years leading cross-functional teams worldwide. Murali’s educational background includes — MS in Computational Data Analytics from Georgia Institute of Technology, MS in Information Technology & Systems design from Southern New Hampshire University, and a BS in Electronics & Communication Engineering from SASTRA University.

To connect with Murali, reach out via — LinkedIn, GitHub.

--

--