In this article we will be going over the Bridge Design Pattern in JavaScript. This is one of the top used patterns that make a significant impact in softare applications. It is a pattern that easily promotes a separation of concerns in its implementation and it's scalable.
Here is diagram depicting this pattern:
There are usually two main participants (or entity, whichever you want to call it) that are involved in the Bridge Pattern.
The first and top most part is the abstract layer. This can be implemented simply as a class:
class Person {
constructor(name) {
this.name = name
}
talk(message) {
console.log(message)
}
}
In the Bridge Pattern, the abstract layer declares the base interface methods and/or properties. However, they do not care about the implementation details because that isn't their job. To be able to reap the advantages of this pattern it must be kept this way so that our code later does not become tightly coupled and remains manageable.
The abstract layer instead opens bridges which then leads to the second main part of the pattern: the implementation layers (which are often implemented as classes in practice) are attached to these bridges, which the client (or you) call the shots. The word "attached" is my form of a human readable term to understand the code term which are references or pointers:
The "bridge" can visibly appear in code like this:
class Theme {
constructor(colorScheme) {
this.colorScheme = colorScheme // Bridge declared
}
getColorScheme() {
return this.colorScheme // Bridge reference/pointer
}
}
If you've visited websites like https://dev.to
or https://medium.com
they have a theme feature that you can access inside your profile. There is usually a toggle theme button. The theme is the abstract layer. The actual implementation in toggling between light and dark are most likely located outside of the abstract layer location within the implementation layer(s).
Where and when should the Bridge Pattern be used?
Some implementations in the real world are coded in a way where the "bridge effect" goes "live" during run time. When you need this type of coupling / binding between two objects this is when you can use the Bridge Pattern to your advantage.
A good example of this is twilio-video, a JavaScript library that lets you add real time voice and video to your web applications (like Zoom). In this library, The Room always instantiates as an empty room. The class keeps a pointer to a LocalParticipant
, (when you join a video chat room you are the LocalParticipant
on your screen) but the LocalParticipant
doesn't actually run or become instantiated yet until it connects and is finished subscribing to the room which is only possible in running code.
If you scan through their code you will spot bridges in a lot of areas. A video chat session cannot be created without a Room
, and a room does not start until there are at least two Participant
s. But a Participant
cannot begin streaming until they start their local audio/video MediaTrack
s. These classes work together in a top down hierarchy. When you start to have multiple classes that are coupled together this is also a good time to consider the Bridge Pattern.
Another scenario where the Bridge Pattern is useful is when you want to share an implementation of some object with multiple objects.
For example, the MediaStreamTrack class represents a media track for a stream. The two most common implementations that "bridge" from it are audio and video tracks.
In addition, the implementation details are usually hidden within the derived classes.
Implementation
Let's implement our own variation of the Bridge Pattern to get a good feel of a problem and solution it brings to the table.
Let's start with a generic Thing
class which can represent any thing:
class Thing {
constructor(name, thing) {
this.name = name
this.thing = thing
}
}
We can create a high level abstraction class that extends Thing
. We can call this LivingThing
and will define a method called eat
. All living things in the real world are born with the ability to eat in order to stay alive. We can mimic this in our code. This will stay in the high level abstract layer:
class LivingThing extends Thing {
constructor(name, bodyParts) {
super(name, this)
this.name = name
// Bridge
this.mouth = bodyParts?.mouth || null
}
eat(food) {
this.mouth.open()
this.mouth.chew(food)
this.mouth.swallow()
return this
}
}
We can see that we opened a bridge to the Mouth
class. Let's define that class next:
class Mouth extends Thing {
constructor() {
super('mouth', this)
}
chew() {}
open() {}
swallow() {}
}
The thing (no pun intended) to consider now is that our Mouth
will be an implementation layer where we write the logic for communicating between the mouth and food.
This implementation is entirely based in Mouth
. The LivingThing
does not care about these implementation details and instead delegates this role entirely to its implementation classes which in our case is Mouth
.
Let's pause and talk about this part for a moment. If LivingThing
is not involved in any of its implementations this is actually a useful concept to us. If we can make other LivingThing
s that only need to provide the interface for implementations to derive from, then we can make a wider range of classes for other scenarios.
In an MMORPG game we can use the LivingThing
and make more of them where they all inherit a pointer to a mouth
automatically:
class Character extends LivingThing {
constructor(name, thing) {
super(name, this)
this.thing = thing
this.hp = 100
this.chewing = null
}
attack(target) {
target.hp -= 5
return this
}
chew(food) {
this.chewing = food
return this
}
eat(food) {
this.hp += this.chewing.hpCount
return this
}
}
class Swordsman extends Character {}
class Rogue extends Character {}
class Archer extends Character {}
class Sorceress extends Character {}
class Potion {
constructor(potion) {
this.potion = potion
}
consume(target) {
if (this.potion) {
this.eat(this.potion)
this.potion = null
}
}
}
class Food {...}
const sally = new Sorceress()
const mike = new Rogue()
mike.attack(sally)
sally.eat(new Food(...))
The bridge pattern is well known to enable developers to build cross-platform applications. We can already see this capability in our examples. We can build this same MMORPG game by reusing LivingThing
on a new code base. We only need to re-implement the implementation layers like Mouth
in order to create bindings to different platforms.
We aren't limited to games. Since our LivingThing
is generic and makes sense for anything that moves it's possible we can use it to create something entirely different like a robot as an IoT device program and simulate eating behavior with LivingThing
.
Going back into our pretend MMORPG game, bridges can be used to create more bridges. MMORPG usually have some profile page where users can edit their settings.
This Profile
can itself utilize the Bridge Design Pattern to define a suite of pieces to make it function like a profile api:
let key = 0
class Profile {
constructor({ avatar, character, gender, username }) {
this.character = null // Bridge
this.gender = null
this.username = username
this.id = ++key
}
setCharacter(value) {
this.character = value
return this
}
setGender(value) {
this.gender = value
if (value === 'female') {
this.showRecommendedEquipments('female')
} else {
this.showRecommendedEquipments('male')
}
return this
}
setUsername(value) {
this.username = value
return this
}
showRecommendedEquipments() {
// Do something with this.character
}
save() {
return fetch(`https://some-database-endpoint.com/v1/profile/${key}`, {
method: 'POST',
body: JSON.stringify({
character: this.character,
gender: this.gender,
username: this.username,
}),
})
}
}
If you've read some of my other articles this might feel similar to the Adapter or Strategy pattern.
There are distinct differences that solve different problems however:
In the Adapter pattern the problem it solves starts from the code (or prior to runtime) where we would construct the Adapter first then immediately start with the rest:
function adapter() {
return function (config) {
var mockAdapter = this
// axios >= 0.13.0 only passes the config and expects a promise to be
// returned. axios < 0.13.0 passes (config, resolve, reject).
if (arguments.length === 3) {
handleRequest(mockAdapter, arguments[0], arguments[1], arguments[2])
} else {
return new Promise(function (resolve, reject) {
handleRequest(mockAdapter, resolve, reject, config)
})
}
}.bind(this)
}
Compare that with our earlier snippets of twilio-video and you will feel the difference immediately.
Conclusion
And that concludes the end of this post! I hope you found this to be valuable and look out for more in the future!
Top comments (0)