Flutter Design Patterns: 17 — Bridge
An overview of the Bridge design pattern and its implementation in Dart and Flutter
In the last article, I have analysed a structural design pattern which provides a way of changing the skin of an object without changing its guts — Decorator. In this article, I would like to analyse and implement another structural design pattern that tends to be relatively difficult to understand compared to the other design patterns, but at the same time is practical and useful — it is Bridge.
Table of Contents
- What is the Bridge design pattern?
- Other articles in this series
- Your contribution
What is the Bridge design pattern?
Bridge, also known as Handle/Body, belongs to the category of structural design patterns. The intention of this design pattern is described in the GoF book:
Decouple an abstraction from its implementation so that the two can vary independently.
The usual way for an abstraction to have one of several possible implementations is to use inheritance — an abstraction defines the interface while concrete subclasses implement it in different ways. However, this approach is not very flexible since it binds the implementation to abstraction at compile-time and makes it impossible to change the implementation at run-time. What if we want for the implementation to be selected and exchanged at run-time?
The Bridge design pattern separates an abstraction from its implementation so that the two can vary independently from each other. In this case, the abstraction uses another abstraction as its implementation instead of using the implementation directly. This relationship between an abstraction and its implementation (well, another abstraction, to be more specific) is called a bridge — it bridges the abstraction and its implementation, letting them vary independently.
If the Abstraction and Implementation terms sound too academic to you, imagine this: abstraction (or interface) is just a high-level layer for some particular entity. This layer is just an interface which is not supposed to do any real work on its own — it should delegate the work to the implementation layer. A good example of this is a GUI (graphical user interface) and OS (operating system). GUI is just a top-level layer for the user to communicate with the operating system, but it does not do any real work by itself — it just passes user commands (events) to the platform. And what is important about this, both GUI and OS could be extended separately from each other, e.g. a desktop application could have different views/panels/dashboards and at the same time support several APIs (could be run on Windows, Linux and macOS) — these two parts could vary independently. Sounds like a Bridge design pattern, right?
The general structure of the Bridge design pattern looks like this:
- Abstraction — defines an interface for the abstraction and maintains a reference to an object of type Implementation;
- Refined abstraction — implements the Abstraction interface and provides different variants of control logic;
- Implementation — defines an interface for the implementation classes. An Abstraction can only communicate with an Implementation object via methods that are declared there;
- Concrete implementations — implement the Implementation interface and contain platform-specific code.
The Bridge design pattern should be used when you want to divide a monolithic class that has several variants of some functionality. In this case, the pattern allows splitting the class into several class hierarchies which could be changed independently — it simplifies code maintenance, smaller classes minimizes the risk of breaking existing code. A good example of this approach is when you want to use several different approaches in the persistence layer e.g. both database and file system persistence.
Also, the bridge design pattern should be used when both the abstractions and their implementations should be extensible by subclassing — the pattern allows combining different abstractions and implementation and extending them independently.
Finally, the bridge design pattern is a lifesaver when you need to be able to switch implementations at run-time. The pattern lets you replace the implementation object inside the abstraction — you can inject it via the constructor or just assign as a new value to a field/property.
For the implementation part, we will implement the persistence layer for our example using the Bridge design pattern.
Let’s say your application uses the external SQL database (not the local SQLite option in your device, but the cloud one). Everything is fine until the wild connection problems appear. In this case, there are two options: you are not allowing users to use the application and provide a funny connection lost screen or you can store the data in some kind of local storage and synchronise the data later when the connection is up again. Obviously, the second approach is more user friendly, but how to implement it?
In the persistence layer, there are multiple repositories for each entity type. The repositories share a common interface — that is our abstraction. If you want to change the storage type (to use the local or cloud one) at run-time, these repositories could not reference the specific implementation of the storage, they should use some kind of abstraction shared between different types of storages. Well, we can build another abstraction (interface) on top of that which is then implemented by the specific storages. Now we connect our repositories’ abstraction with the storages’ interface — voilà, that is how the Bridge design pattern is introduced into our application! Let’s check the class diagram first and then investigate some implementation details.
The class diagram below shows the implementation of the Bridge design pattern:
The EntityBase is an abstract class which is used as a base class for all the entity classes. The class contains an id property and a named constructor EntityBase.fromJson to map the JSON object to the class field.
Customer and Order are concrete entities which extend the abstract class EntityBase. Customer class contains name and email properties, Customer.fromJson named constructor to map the JSON object to class fields and a toJson() method to map class fields to the corresponding JSON map object. Order class contain dishes (a list of dishes of that order) and total fields, a named constructor Order.fromJson and a toJson() method respectively.
IRepository is an abstract class which is used as an interface for the repositories:
- getAll() — returns all records from the repository;
- save() — saves an entity of type EntityBase in the repository.
CustomersRepository and OrdersRepository are concrete repository classes which extend the abstract class IRepository and implement its abstract methods. Also, these classes contain a storage property of type IStorage which is injected into the repository via the constructor.
IStorage is an abstract class which is used as an interface for the storages:
- getTitle() — returns the title of the storage. The method is used in UI;
- fetchAll<T>() — returns all the records of type T from the storage;
- store<T>() — stores a record of type T in the storage.
FileStorage and SqlStorage are concrete storage classes which extend the abstract class IStorage and implement its abstract methods. Additionally, FileStorage class uses the JsonHelper class and its static methods to serialise/deserialise JSON objects.
BridgeExample initialises and contains both — customer and order — repositories which are used to retrieve the corresponding data. Additionally, the storage type of these repositories could be changed between the FileStorage and SqlStorage separately and at the run-time.
An abstract class which stores the id field and is extended by all of the entity classes.
A simple class to store information about the customer: its name and email. Also, the constructor generates random values when initialising the Customer object.
A simple class to store information about the order: a list of dishes it contains and the total price of the order. Also, the constructor generates random values when initialising the Order object.
A helper classes used by the FileStorage to serialise objects of type EntityBase to JSON map objects and deserialise them from the JSON string.
An interface which defines methods to be implemented by the derived repository classes. Dart language does not support the interface as a class type, so we define an interface by creating an abstract class and providing a method header (name, return type, parameters) without the default implementation.
- CustomersRepository — a specific implementation of the IRepository interface to store customers’ data.
- OrdersRepository — a specific implementation of the IRepository interface to store orders’ data.
An interface which defines methods to be implemented by the derived storage classes.
- FileStorage — a specific implementation of the IStorage interface to store an object in the storage as a file — this behaviour is mocked by storing an object as a JSON string.
- SqlStorage — a specific implementation of the IStorage interface to store an object in the storage as an entity — this behaviour is mocked by using the Map data structure and appending entities of the same type to the list.
First of all, a markdown file is prepared and provided as a pattern’s description:
BridgeExample contains a list of storages — instances of SqlStorage and FileStorage classes. Also, it initialises Customer and Order repositories. In the repositories the concrete type of storage could be interchanged by triggering the onSelectedCustomerStorageIndexChanged() for the CustomersRepository and onSelectedOrderStorageIndexChanged() for the OrdersRepository respectively.
The concrete repository does not care about the specific type of storage it uses as long as the storage implements the IStorage interface and all of its abstract methods. As a result, the abstraction (repository) is separated from the implementor (storage) — the concrete implementation of the storage could be changed for the repository at run-time, the repository does not depend on its implementation details.
As you can see in the example, the storage type could be changed for each repository separately and at run-time — it would not be possible by using the simple class inheritance approach.
All of the code changes for the Bridge design pattern and its example implementation could be found here.
Other articles in this series
👏 Press the clap button below to show your support and motivate me to write better!
💬 Leave a response to this article by providing your insights, comments or wishes for the series.
📢 Share this article with your friends, colleagues in social media.
➕ Follow me on Medium.
⭐ Star the Github repository.