Understanding the Bridge Design Pattern
• • 13 min readThere are 23 classic design patterns described in the original book Design Patterns: Elements of Reusable Object-Oriented Software. These patterns provide solutions to particular problems often repeated in software development.
In this article, I am going to describe how the Bridge pattern works and when it should be applied.
Bridge: Basic Idea
According to Wikipedia, the Bridge pattern is a structural software design pattern that separates an abstraction from its implementation, so that both can vary independently. This pattern promotes composition over inheritance by decoupling the abstraction from its implementation. The main idea is to have two independent class hierarchies, one for the abstraction and another for the implementation, and then connect them together using a bridge.
On the other hand, the definition provided by the original book is as follows:
Separate an abstraction from its implementation so that both can vary independently.
In many cases, we encounter situations where we want the flexibility to change both the abstraction and the implementation of an object independently. For example, in a drawing system, we may have different shapes (abstractions) that can be drawn in different ways (implementations). This is where the Bridge design pattern can help us write more flexible and maintainable code.
Next, let’s take a look at the UML class diagram of this pattern to understand each of the elements that interact in it.
These are the classes that comprise this pattern:
- Abstraction: This is the interface of interest to clients. It maintains a reference to an
Implementor
object that defines the abstract interface for the implementation classes. - RefinedAbstraction: This class extends the interface defined by
Abstraction
. It can add additional methods or functionality specific to the abstraction. - Implementor: This defines the interface for the implementation classes. It does not have to correspond directly to
Abstraction’s
interface. - ConcreteImplementorA and ConcreteImplementorB: These are the subclasses that implement the
Implementor
interface. EachConcreteImplementor
provides a specific implementation of the interface defined byImplementor
.
By separating the abstraction from its implementation and allowing them to vary independently, the Bridge pattern enables greater flexibility and extensibility in our codebase.
Bridge Pattern: When to Use It
The Bridge design pattern is particularly useful in the following scenarios:
- You want to separate an abstraction from its implementation: The Bridge pattern is ideal when you need to have the flexibility to vary both the abstraction and the implementation independently. By decoupling these two aspects, you can make changes to one without affecting the other.
- You have multiple hierarchies that need to be extended independently: When you have two or more orthogonal class hierarchies that need to be extended independently, the Bridge pattern provides an elegant solution. It allows you to manage each hierarchy separately and then connect them as needed through the bridge.
- You want to avoid a proliferation of subclasses: Without the Bridge pattern, you might end up with a large number of subclasses, each combining different abstractions with different implementations. This can lead to a complex and rigid class hierarchy. By using the Bridge pattern, you can avoid this proliferation of subclasses and keep your codebase more manageable.
In summary, the Bridge pattern is beneficial when you need to decouple abstraction from implementation, manage multiple hierarchies independently, and avoid a proliferation of subclasses.
Bridge Pattern: Advantages and Disadvantages
The Bridge pattern offers several advantages, which can be summarized as follows:
Advantages
- Decoupling of abstraction and implementation: The Bridge pattern promotes decoupling by separating an abstraction from its implementation. This allows changes to one side of the bridge to occur without affecting the other side, thus enhancing flexibility and maintainability.
- Enhanced extensibility: By allowing both the abstraction and implementation to vary independently, the Bridge pattern facilitates easier extension of the system. New abstractions or implementations can be introduced without requiring modifications to existing code, thereby adhering to the Open-Closed Principle.
- Reduced Class Proliferation: The Bridge pattern helps prevent class explosion by avoiding the creation of a large number of subclasses to handle different combinations of abstractions and implementations. Instead, it promotes a more modular and manageable class structure.
However, like most design patterns, the Bridge pattern also comes with its drawbacks.
Disadvantages
- Increased complexity: Implementing the Bridge pattern may introduce additional complexity to the codebase, as it involves defining multiple interfaces and classes to manage the separation between abstraction and implementation.
- Higher initial overhead: Initially, applying the Bridge pattern may require more effort to set up the necessary abstractions, implementations, and bridges. This upfront cost may deter developers from using the pattern in simpler scenarios.
In summary, while the Bridge pattern offers advantages such as decoupling and extensibility, it also requires careful consideration of the trade-offs, particularly regarding code complexity and initial overhead.
Bridge Pattern Examples
Next, we will illustrate the Bridge pattern with two examples:
- Basic Structure of the Bridge Pattern: In this example, we will translate the theoretical UML diagram into TypeScript code to identify each of the classes involved in the pattern.
- Multiplatform Messaging System: Let’s consider a messaging system that needs to function on both mobile devices and desktop computers. Without the Bridge pattern, the code could become tightly coupled to specific platforms, requiring significant code rewriting whenever a platform change is needed. We will first demonstrate the code without applying the Bridge pattern, and then we will show how applying the pattern can help decouple the code from platform-specific implementations.
Example 1: Basic Structure of the Bridge Pattern
First of all, we can see the UML class diagram of the implementation using the Bridge design pattern.
So let’s start looking at its implementation in code. The first element of this design pattern is the Implementor
interface, where the different operations that we want all concrete implementations to have are defined.
export interface Implementor {
operationImplementation(): void;
}
The next step would be to see the concrete classes that satisfy the concrete interface; in our example, we are only defining a single method for our classes. This first branch focuses on the concrete implementations, not on the abstractions.
import { Implementor } from './implementor';
export class ConcreteImplementorA implements Implementor {
operationImplementation(): void {
console.log("ConcreteImplementorA operation implementation.");
}
}
export class ConcreteImplementorB implements Implementor {
operationImplementation(): void {
console.log("ConcreteImplementorB operation implementation.");
}
}
The second part of the UML class diagram shows the part corresponding to the abstractions. The Abstraction
class is an abstract class and therefore cannot be instantiated, but the key here is that it has an attribute that must adhere to the Implementor
interface. In this case, we have achieved composition through the constructor of the Abstraction
class; of course, we could have done it through an accessor method. The other key point of this abstraction class is that there is an abstract method, in our case operation, which will be implemented in a class that extends this abstract class.
import { Implementor } from './implementor';
export abstract class Abstraction {
protected implementor: Implementor;
constructor(implementor: Implementor) {
this.implementor = implementor;
}
abstract operation(): void;
}
If we take a look at the last class of the refine abstraction design pattern, we see that it extends the abstraction class, and there we have the concrete implementation of the operation method, which uses an instance of a class that satisfies the Implementor
interface, which could be either concreteImplementorA
or concreteImplementorB
.
import { Abstraction } from './abstraction';
export class RefinedAbstraction extends Abstraction {
operation(): void {
console.log('RefinedAbstraction: operation');
this.implementor.operationImplementation();
}
}
Finally, let’s take a look at the class that utilizes the bridge pattern.
The client
class instantiates both concreteImplementor
classes, which are then used to create an instance of the refineAbstraction
class, with the desired concreteImplementor
passed as a parameter. Ultimately, it is the abstraction class that invokes the refined abstraction.
import { Abstraction } from './abstraction';
import { ConcreteImplementorA } from './concrete-implementorA';
import { ConcreteImplementorB } from './concrete-implementorB';
import { RefinedAbstraction } from './refine-abstraction';
class Client {
static main(): void {
const implementorA = new ConcreteImplementorA();
const implementorB = new ConcreteImplementorB();
let abstraction: Abstraction = new RefinedAbstraction(implementorA);
abstraction.operation();
abstraction = new RefinedAbstraction(implementorB);
abstraction.operation();
}
}
Client.main();
In this way, we have decoupled the implementation from the abstraction.
If anyone has noticed that the structure of this design pattern resembles surprisingly the template-method and strategy patterns, we will leave a comparison of these three patterns at the end of the post. Now let’s move on to a different example, where we will start by observing the lack of use of this pattern and then the solution with the pattern.
Example 2: Multiplatform Messaging System — Problem
Let’s think about a messaging system that needs to work on both mobile and desktop devices. Without the Bridge pattern, the code could be tightly coupled to specific platforms, making any changes to the platform require significant code rewriting.
If we look at the UML class diagram, we find two different classes that have two different implementations for each messaging system. On one side, we have the MobileMessaging
class, and on the other side, DesktopMessaging
. Both classes implement methods for sending and receiving messages. In the case of the DesktopMessaging
class, the methods are named sendMessageFromDesktop
and receiveMessageOnDesktop
, while for the MobileMessaging
class, the methods are named sendMessageFromMobile
and receiveMessageOnMobile
.
export class DesktopMessaging {
sendMessageFromDesktop(message: string): void {
console.log("Sending message from a desktop: " + message);
}
receiveMessageOnDesktop(): string {
return "Message received on a desktop.";
}
}
export class MobileMessaging {
sendMessageFromMobile(message: string): void {
console.log("Sending message from a mobile device: " + message);
}
receiveMessageOnMobile(): string {
return "Message received on a mobile device.";
}
}
On the other hand, we have a client that directly uses both classes, instantiating them and causing direct coupling to these two classes. If there is any change in them now, we don’t have a common interface acting as a contract between the classes, leading to direct coupling. Let’s see the implementation.
The code for these classes is quite simple, as we can see. Both classes have the two methods that either display a message via console.log
or return a string, nothing more.
import { DesktopMessaging } from "./desktop-messaging";
import { MobileMessaging } from "./mobile-messaging";
class Client {
static main(): void {
const mobileMessaging = new MobileMessaging();
mobileMessaging.sendMessageFromMobile("Hello from Mobile!");
console.log(mobileMessaging.receiveMessageOnMobile());
const desktopMessaging = new DesktopMessaging();
desktopMessaging.sendMessageFromDesktop("Hello from Desktop!");
console.log(desktopMessaging.receiveMessageOnDesktop());
}
}
Client.main();
In the Client
class, we can see how we have to instantiate two different objects of the MobileMessaging
and DesktopMessaging
classes. Then, we use the methods specific to each of these classes. In one case, sendMessageFromMobile
, and in the other, sendMessageFromDesktop
. Here, we detect, on one hand, the lack of a common interface that could unify these methods, but also, we notice that if there is a change in these methods, the code is tightly coupled, and the client must be completely redefined. Furthermore, the example also ends up using the receiveMessageOnMobile
and receiveMessageOnDesktop
methods, which have the same problem as the previous methods. Additionally, in case the classes are extended through inherited classes, we will encounter an explosion of unnecessary classes.
Next, we will see the solution using the Bridge design pattern.
Example 2: Multiplatform Messaging System — Solution
As we’ve discussed, the problems that arise in this solution without the Bridge design pattern are as follows:
- Code duplication: Each class has very similar methods, leading to code duplication.
- High cohesion: The messaging logic is tightly coupled to the specific platform, making it difficult to make changes to the messaging logic without affecting the specific platform implementation.
- Scalability and maintainability: Adding support for new platforms or changing messaging behavior requires extensive modifications or code duplication.
In this example, we clearly see the need for the Bridge pattern, where we can separate the abstraction meaning the messaging operations, from its implementation which would be the specific platform details, allowing both to vary independently. In the next step, let’s refactor this code to implement the Bridge pattern and address these issues.
Let’s start by looking at how the UML class diagram evolves.
First, we see in the diagram the MessageSender
interface that we needed in the code without the pattern. This interface defines the methods sendMessage
and receiveMessage
. This interface is common to all concrete classes.
In this case, the concrete implementations for the desktop and mobile platforms are implemented in the DesktopSender
and MobileSender
classes. We have the concrete implementations on this side, and now we need to see the abstractions.
If we look at the MessagingService
class, it is an abstract class that includes an object that satisfies the MessageSender
interface, which can be either desktop or mobile, or even if there were a new platform like XBOXSender
, it would still work as long as it adheres to the MessageSender
interface. Because this is an abstract class, we need to have a class that extends it and implements the abstract methods of this class, namely, the send and receive methods.
The AdvancedMessaging
class is responsible for implementing the send
and receive
methods. Of course, in our case, it will make use of the sender instance. Finally, the client will only use the AdvancedMessaging
class and the specific platform it needs. Let’s see this solution through the source code.
Let’s see this solution through the source code.
export interface MessageSender {
sendMessage(message: string): void;
receiveMessage(): string;
}
Let’s start by looking at the MessageSender
interface, which simply defines the two operations we want to perform, sendMessage
and receiveMessage
.
import { MessageSender } from './message-sender';
export class DesktopSender implements MessageSender {
sendMessage(message: string): void {
console.log("Sending message from a desktop: " + message);
}
receiveMessage(): string {
return "Message received on a desktop.";
}
}
import { MessageSender } from './message-sender';
export class MobileSender implements MessageSender {
sendMessage(message: string): void {
console.log("Sending message from a mobile device: " + message);
}
receiveMessage(): string {
return "Message received on a mobile device.";
}
}
In this case, the codes are identical to those we had in the solution without the pattern. In fact, this is because these are the concrete implementations we had in the solution without the pattern. Therefore, here we have simply created a common interface for both classes. This simple change could have been made without having the bridge pattern, and we would already have gained in decoupling our first solution. However, what the bridge pattern will allow us to do is to grow in the concrete implementations on the one hand and the abstractions on the other.
The abstract MessagingService
class has an attribute that satisfies the MessageSender
interface. In this case, we include it through the constructor but we could do it from an accessor method.
The key is that we define the methods that we want the abstractions to have, in our case the send and receive methods that will be implemented in the hierarchy of abstraction classes.
import { MessageSender } from './message-sender';
export abstract class MessagingService {
protected sender: MessageSender;
constructor(sender: MessageSender) {
this.sender = sender;
}
abstract send(message: string): void;
abstract receive(): string;
}
In the following concrete implementation of the previous abstraction, we extend the MessagingService
class and therefore, we already have the sender attribute thanks to the parent class. Now, we make the concrete implementation of the send
and receive
methods, where in both cases we are making use of the methods of the instance of the sender attribute, delegating the responsibility to this instance.
import { MessagingService } from './messaging-sender';
export class AdvancedMessaging extends MessagingService {
send(message: string): void {
this.sender.sendMessage(message);
}
receive(): string {
return this.sender.receiveMessage();
}
}
If we look at the final code, we can see that when we are creating the platform to send mobile messages, we will create an instance of the AdvancedMessaging
abstraction by receiving an object of the MobileSender
instance. On the other hand, if it were to send desktop messages, it would be exactly the same but instead of passing a MobileSender
instance as an argument, it would be a DesktopSender
instance. Later, on both platforms we will use the methods that we have in the interface, the send methods to send messages and the receive method to receive them.
import { AdvancedMessaging } from "./advanced-messaging";
import { DesktopSender } from "./desktop-sender";
import { MobileSender } from "./mobile-sender";
class Client {
static main(): void {
// Use mobile sender
const mobileMessaging = new AdvancedMessaging(new MobileSender());
mobileMessaging.send("Hello from Mobile!");
console.log(mobileMessaging.receive());
// Use desktop sender
const desktopMessaging = new AdvancedMessaging(new DesktopSender());
desktopMessaging.send("Hello from Desktop!");
console.log(desktopMessaging.receive());
}
}
Client.main();
This design using the bridge pattern allows you to easily change the message sending implementation without modifying the rest of the client code or the messaging application logic. Each component can be developed, tested, and maintained independently.
Differences between Bridge, Strategy, and Template Method
Although the Bridge, Strategy, and Template Method patterns share certain similarities, each one has a different purpose and structure. Here’s a description of the key differences between them:
Bridge Pattern:
- Purpose: The Bridge pattern focuses on separating an abstraction from its implementation so that both can vary independently.
- Structure: It typically involves defining an abstraction hierarchy and an implementation hierarchy, with a bridge connecting the two hierarchies.
- Use Cases: It is useful when there is a need to support multiple implementations of an abstraction, or when changes in one part of the system should not affect unrelated parts.
Strategy Pattern:
- Purpose: The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. It allows the algorithm to vary independently from the client that uses it.
- Structure: It involves defining a strategy interface that represents a family of algorithms, and concrete implementations of these algorithms.
- Use Cases: It is beneficial when there are multiple algorithms for a specific task and the client needs to select or switch between them dynamically.
Template Method Pattern:
- Purpose: The Template Method pattern defines the skeleton of an algorithm in a method, deferring some steps to subclasses. It allows subclasses to redefine certain steps of an algorithm without changing its structure.
- Structure: It typically involves defining an abstract class that contains the template method (the algorithm skeleton) and abstract methods that subclasses must implement.
- Use Cases: It is useful when there is a common algorithm structure shared among multiple subclasses, but the specific implementation of certain steps may vary.
In summary, while the Bridge pattern focuses on separating abstraction from implementation, the Strategy pattern deals with encapsulating interchangeable algorithms, and the Template Method pattern defines the skeleton of an algorithm with certain steps delegated to subclasses. Each pattern addresses different concerns and can be applied in different scenarios based on the specific requirements of the system.
Conclusions
Some conclusions about the use of the Bridge design pattern:
- Abstraction and Implementation Separation: The Bridge pattern allows for the separation of abstraction from its implementation, facilitating the modification and extension of both independently. This promotes a more flexible and scalable design.
- Coupling Reduction: By separating abstraction from implementation, the Bridge pattern helps reduce coupling between parts of the system. This makes the code easier to maintain and prevents the propagation of changes in one part of the system to unrelated parts.
- Support for Various Platforms: The Bridge pattern is especially useful when working with multiple platforms or operating systems. It allows an abstraction to have multiple implementations that are specific to each platform, facilitating adaptation to different environments.
- Promotion of Reusability and Modularity: By defining a hierarchy of abstraction and implementation, the Bridge pattern encourages code reuse and modularity. Abstractions and implementations can be easily exchanged and reused in different contexts, reducing duplication and promoting cohesion in software design.
- Additional Complexity: Although the Bridge pattern can improve code flexibility and maintainability, it also introduces an additional layer of complexity in the design. This may require extra effort to understand and maintain the code, especially in small or simple systems where the separation between abstraction and implementation may seem unnecessary.
Overall, the Bridge pattern is a powerful tool for designing flexible and modular systems, especially when working with multiple platforms or operating systems. However, its use should be carefully evaluated based on the specific requirements of the project and the balance between flexibility and complexity in software design.
The most important thing about this pattern is not the concrete implementation of it but the ability to recognize the problem that this pattern can solve and when it can be applied. The specific implementation isn’t as important since that will vary depending on the programming language used.
The GitHub’s repository is the following:
https://github.com/Caballerog/blog/tree/master/bridge-pattern