An overview of design patterns
To gain a better understanding of specific CI/CD and its relationship with design patterns, it is important to first understand the origin of design patterns and get an overview of the various types that are available. This will help you to understand the purpose and usage of design patterns in general.
Design patterns are general, reusable solutions to common problems that occur during the design and development of software. They represent best practices to solve specific types of issues and provide developers with a way to communicate effective design solutions. These patterns contain the best practices that have evolved over time by experienced software developers to address specific challenges in designing robust, maintainable, and scalable software systems.
The book Design Patterns – Elements of Reusable Object-Oriented Software, published in 1994 by four authors known as the Gang of Four, popularized the term design patterns. However, the concept of design patterns can be traced back to earlier works, such as the book A Pattern Language (1977) by Christopher Alexander and his colleagues. This book proposed patterns for architecture and urban planning.
Design patterns help developers to create software architectures that are flexible, modular, and easy to understand. They are not specifically about coding but, rather, about providing solutions to recurring questions, issues, and problems that engineers come across when coding a software solution. You can think of them as a prescription for ensuring a higher quality of code.
There are a few key features of design patterns that are worth noting:
- Reusability: Firstly, they promote reusability by encapsulating solutions to common design problems. This means that developers can apply proven solutions to similar problems in various parts of their applications.
- Abstraction: Secondly, patterns provide a level of abstraction, which makes it easier for developers to explain the design of a system. They serve as a common vocabulary for discussing and documenting software architectures.
- Maintainability: Using design patterns can lead to more maintainable code by making it easier for developers to understand and modify code that follows established and documented patterns.
- Flexible and adaptable: Finally, design patterns help create flexible and adaptable software systems. They enable developers to build systems that can evolve and accommodate changes without requiring a complete overhaul.
In the next section, we will discuss types of common design patterns to provide a foundational understanding of the design patterns.
Types of design patterns
In software development, various design patterns are utilized for specific use cases. These patterns allow for the reuse of proven techniques, thereby avoiding the need to reinvent the wheel. Consistent principles and conventions are followed in the design patterns, making them reliable and easy to implement. Design patterns provide a systematic approach to software design and can be beneficial for developers. They can broadly be categorized into creational, structural, and behavioral patterns.
Creational patterns
Creational patterns refer to a group of design patterns that primarily deal with the process of creating objects. These patterns are particularly useful in managing and maintaining object-creation processes by providing various techniques and strategies to create objects in a structured and efficient manner. Some examples of creational patterns include Singleton, Factory Method, and Abstract Factory, each of which offers unique solutions to create objects in different contexts.
A commonly used design pattern is the Singleton pattern, which is a creational pattern that ensures only one instance of a class exists and provides a global point of access to that instance. This pattern is useful when only a single object is needed to coordinate actions across a system, such as a configuration manager, logging service, database connection, or connection pool. Here is a diagram containing an example of a Singleton pattern for a logger class.
Figure 1.1 – An example of a Singleton pattern for a logger class
The Logger
class in the example uses the Singleton design pattern to ensure that only one instance of the class is created, which can then be accessed globally. The class has a private static variable, instance
, which holds the single instance of the class. Whenever the getInstance()
method is called, it initializes the instance if it’s null.
Another common creational pattern is the Factory Method pattern. The Factory Method pattern is a fundamental design pattern in object-oriented programming that encapsulates an object’s instantiation process. It defines an interface for object creation, allowing subclasses to specify the type of objects to be created. Essentially, it involves a creator class with a factory method that is either abstract or has a default implementation to create objects. Subclasses have the option to override this method in order to modify the class of objects that will be created. This pattern is especially helpful when a class cannot predict the type of objects it needs to create, or when a class wants its subclasses to specify the objects it creates. The Factory Method pattern promotes loose coupling and enhances scalability, as new classes can be introduced without altering the creator’s code.
Let’s have a look at a graphical representation of this pattern:
Figure 1.2 – The Factory Method pattern
Imagine a logistics management application that needs to create different types of vehicles such as trucks, ships, and airplanes. We can define an abstract class called Transport
with a method, deliver()
. Then, we have subclasses such as Truck
, Ship
, and Airplane
that implement the deliver()
method. The TransportFactory
is an abstract class with a createTransport()
method, which is the factory method. Subclasses of TransportFactory
, such as TruckFactory
, ShipFactory
, and AirplaneFactory
, implement the createTransport()
method to instantiate and return an object of the respective type. When the application needs a vehicle, it simply calls the createTransport()
method on the relevant factory without worrying about the instantiation details. This way, the Factory Method pattern allows the application to remain flexible and scalable, as new types of vehicles can be added without modifying the existing codebase.
Finally, there is the Abstract Factory pattern. This pattern is essential in scenarios where a system needs to be independent for object creation, composition, and representation. It acts as a super-factory, creating other factories; this pattern is also known as a factory of factories. This pattern is particularly useful when a system must be configured with one of multiple families of products that are only known at runtime. The pattern provides interfaces to create families of related objects without specifying their concrete classes. By doing so, it encapsulates the creation of objects in a separate object, allowing for the interchangeability of concrete implementations without altering the code that uses them. Despite its advantages, the Abstract Factory pattern can introduce extra complexity and abstraction, which might not be necessary in simpler applications. It’s a powerful tool in the developer’s toolkit, but like all tools, it should be used appropriately. This is explored in the following diagram, based on a real-life example:
Figure 1.3 – Abstract Factory pattern
When building a CI/CD system for a software project, the Factory Method pattern can be applied to create different types of deployment pipelines, each with specific steps, tools, and configurations. This involves creating an abstract class or interface called PipelineCreator
with a factory method, createPipeline()
, and implementing concrete subclasses of PipelineCreator
for each type of pipeline (e.g., DevelopmentPipelineCreator
, StagingPipelineCreator
, and ProductionPipelineCreator
). Additionally, an abstract class or interface called DeploymentPipeline
represents the common interface for all deployment pipelines, and concrete classes such as DevelopmentPipeline
, StagingPipeline
, and ProductionPipeline
are created to implement the specific steps to deploy to different environments.
Next, we will take a look at Structural patterns.
Structural patterns
A structural pattern is a type of design pattern that aims to simplify the arrangement of classes or objects in a software system. Structural patterns provide well-defined and effective methods for classes and objects to collaborate, resulting in improved flexibility, maintainability, and reusability. These patterns specifically deal with the arrangement of classes and objects and how they can be merged to create more complex structures. Structural patterns also provide guidelines to create a more flexible and maintainable structure in your software design. A few examples of structural patterns are as follows:
- Adapter pattern: This pattern helps you to use an already existing class in place of another class. For example, it can be used to make an old version of a class work with a new interface.
- Bridge pattern: This pattern separates a concept from its implementation so that it can be changed independently. It can be used to connect different database implementations to a system. For example, the Bridge pattern separates abstraction from implementation, allowing different shapes to be created using different drawing APIs, without changing the shape or drawing API classes. The
shape
class delegates drawing operations to a drawing API object, which has different subclasses that implement different drawing methods. Clients can create any shape with any drawing API by passing appropriate objects to the shape constructor. - Composite pattern: This pattern is used to create tree-like structures to represent hierarchical relationships between parts and wholes. It can be used to create complex graphical user interfaces where both individual elements and composite elements are treated equally. For example, the composite pattern is a useful design pattern that treats a group of objects as a single unit. It comprises three main components – the component interface, the leaf class, and the composite class. The component interface defines shared behavior for all components, the
leaf
class represents individual objects with no children, and thecomposite
class represents complex objects with one or more children.
Let’s talk about behavioral patterns now.
Behavioral patterns
Behavioral patterns in software development are a type of design pattern that focus on the interactions and communication between different objects. They help to define the roles and responsibilities of each component in a system and how they work together to achieve a common goal. Types of behavioral patterns include Observer, Strategy, Command, Mediator, and Iterator, explained as follows:
- Observer pattern: The Observer pattern, which is widely used in software development, defines a relationship between a subject and multiple observers. The subject maintains a list of observers and notifies them of any state changes. This allows the observers to react accordingly based on their own logic.
To give an example of an implementation scenario, the Observer pattern is useful when you want to decouple the components of a system that need to be informed of certain events or changes. For example, the Observer pattern can be used to implement a notification system that sends alerts to different channels (email, SMS, push notification, etc.) whenever a new order is placed or a payment is received. Another example is a dashboard that displays various metrics and charts that update automatically when the data source changes.
The following figure shows you a representation of the Observer pattern:
Figure 1.4 – An example of the Observer pattern
The preceding diagram shows how the Observer
class receives metric and chart updates of a few observers (Metric
and Chart
) for DataSource
. When DataSource
has new metrics data, it calls notifyObservers()
, which in turn calls update()
on each registered observer, such as MetricsObserver
, to inform them of the changes. This enables a decoupled and flexible design where observers can be added or removed without modifying DataSource
.
- Strategy patterns: A Strategy pattern is a type of design pattern that enables a family of algorithms to be defined and switched out easily. Strategy patterns allow for the algorithms to be enclosed and varied independently from the clients that utilize them. Essentially, these patterns facilitate the separation of an algorithm’s strategy from its execution, enhancing the code’s adaptability and reusability.
The best way to describe it is by this example. Suppose you have a class called
Animal
that has a method calledmakeSound()
. Different animals make different sounds, so you want to implement this method differently for each subclass ofAnimal
. However, you don’t want to hardcode the sound logic in each subclass, as that would violate the open-closed principle and make your code less flexible and maintainable. One way to solve this problem is to use a Strategy pattern. The following figure illustrates this example that applies to the Strategy pattern:
Figure 1.5 – An example of a strategy design pattern
Here, you can change the behavior of the makeSound()
method at runtime by passing different strategy objects that represent various animal sounds, such as meow or bark. This makes the code more flexible and maintainable, as you can add new behaviors without altering existing code.
- Command patterns: A Command pattern is a type of design pattern that encapsulates a request as an object. This allows a request to be parameterized, queued, logged, or undone. Command patterns are useful for implementing user interfaces, network protocols, and transactional systems. These patterns decouple the sender of a request from the receiver, making the code more modular and reusable. For example, undo/redo operations (implemented by a created
command
object), macro recording (to record a sequence of commands), and menu actions (also implemented as a command object) are all executed through a method that performs the specific action. - Mediator patterns: The Mediator pattern is designed to simplify and streamline communication between objects while reducing dependencies and complexity. This is achieved by encapsulating the interactions between objects in a mediator object, which acts as an intermediary and defines an interface for communication between the objects. Using this pattern, objects don’t need to know each other’s identities or references, and they can vary their interactions independently. Mediator patterns can help make code maintenance easier, improve the modularity and testability of a system, and facilitate code reuse.
For example, using a Mediator object makes it possible to allow other objects to be loosely coupled, as the mediator coordinates interactions between the objects. It’s useful when you have a large number of objects that need to communicate but you want to avoid that same large number of references between them. In the following diagram, the
Mediator
class defines an interface to communicate with colleagues:
Figure 1.6 – An example of the Mediator pattern
- Iterator patterns: The Iterator pattern is a common design technique in software engineering that allows sequential access to the elements of a collection, without exposing its underlying representation. Iterator patterns can be implemented in various ways, such as using cursors, generators, coroutines, or closures. The main benefits of Iterator patterns are that they decouple the client code from the collection implementation, support multiple simultaneous traversals, and enable lazy evaluation of the elements.
In Java, the
Iterator
interface provides methods to iterate over any collection of objects, regardless of how they are stored internally. Here is an example diagram of an Iterator pattern to read a file line by line.
Figure 1.7 – An example of the Iterator design pattern
This concludes this section. Next, we will see how all these design patterns relate to CI/CD.