The bridge design pattern

Some applications can have multiple different implementations of a specific functionality. The implementations could be in the form of different algorithms or something do to with multiple platforms. The implementations tend to vary often and they could also have new implementations throughout the lifecycle of a program. Moreover, the implementations could be used in different ways for different abstractions. In cases like these, it is good to decouple things in our code, or else we are in danger of a class explosion.

Note

The purpose of the bridge design pattern is to decouple an abstraction from its implementation so that the two can vary independently.

The bridge design pattern is quite useful in the cases where the abstractions or the implementations could vary often and independently. If we directly implement an abstraction, variations to the abstraction or the implementations would always affect all other classes in the hierarchy. This makes it hard to extend, modify, and reuse classes independently.

The bridge design pattern eliminates a problem by directly implementing an abstraction, thus making the abstractions and implementations reusable and easier to change.

The bridge design pattern is very similar to the adapter design pattern. The difference between them is that in the former, we apply it when we design our application and the latter is used for legacy or third-party code.

Class diagram

For the class diagram and the code example, let's imagine that we are writing a library that hashes passwords. In practice, storing passwords in plain text is something that should be avoided. This is what our library will help our users to do. There are many different hashing algorithms that can be used. Some are SHA-1, MD5, and SHA-256. We want to be able to support at least these and have the possibility to add new ones easily. There are different hashing strategies—you can hash multiple times, combine different hashes, add salt to the passwords, and so on. These strategies make our passwords harder to guess using rainbow tables, for example. For this example, we will show hashing with salt and simple hashing with any of the algorithms we have.

Here is our class diagram:

Class diagram

As you can see from the preceding diagram, we separated the implementation (Hasher and it's implementations) from the abstraction (PasswordConverter). This allows us to easily add a new hashing implementation and then instantly use it by just providing an instance of it when creating a PasswordConverter. If we hadn't used the preceding builder pattern, we would probably have to create a password converter for each hashing algorithm separately—something that would make our code explode in size or become tedious to use.

Code example

Let's now have a look at the previous class diagram from the point of view of Scala code. First, we will focus on the implementation side with the Hasher trait:

trait Hasher {
  def hash(data: String): String
  
  protected def getDigest(algorithm: String, data: String) = {
    val crypt = MessageDigest.getInstance(algorithm)
    crypt.reset()
    crypt.update(data.getBytes("UTF-8"))
    crypt
  }
}

Then, we have three classes that implement it—Md5Hasher, Sha1Hasher, and Sha256Hasher. Their code is pretty simple and similar, but yields different results:

class Sha1Hasher extends Hasher {
  override def hash(data: String): String = new String(Hex.encodeHex(getDigest("SHA-1", data).digest()))
}

class Sha256Hasher extends Hasher {
  override def hash(data: String): String = new String(Hex.encodeHex(getDigest("SHA-256", data).digest()))
}

class Md5Hasher extends Hasher {
  override def hash(data: String): String = new String(Hex.encodeHex(getDigest("MD5", data).digest()))
}

Let's now take a look at the abstraction side of things. This is what our clients will use. The following listing shows the PasswordConverter:

abstract class PasswordConverter(hasher: Hasher) {
  def convert(password: String): String
}

We have chosen to provide two different implementations here—SimplePasswordConverter and SaltedPasswordConverter. The code for them is as follows:

class SimplePasswordConverter(hasher: Hasher) extends PasswordConverter(hasher) {
  override def convert(password: String): String = hasher.hash(password)
}

class SaltedPasswordConverter(salt: String, hasher: Hasher) extends PasswordConverter(hasher) {
  override def convert(password: String): String = hasher.hash(s"${salt}:${password}")
}

Now, if a client wanted to use our library, they could write a program similar to the following one:

object BridgeExample {
  
  def main(args: Array[String]): Unit = {
    val p1 = new SimplePasswordConverter(new Sha256Hasher)
    val p2 = new SimplePasswordConverter(new Md5Hasher)
    val p3 = new SaltedPasswordConverter("8jsdf32T^$%", new Sha1Hasher)
    val p4 = new SaltedPasswordConverter("8jsdf32T^$%", new Sha256Hasher)
    
    System.out.println(s"'password' in SHA-256 is: ${p1.convert("password")}")
    System.out.println(s"'1234567890' in MD5 is: ${p2.convert("1234567890")}")
    System.out.println(s"'password' in salted SHA-1 is: ${p3.convert("password")}")
    System.out.println(s"'password' in salted SHA-256 is: ${p4.convert("password")}")
  }
  
}

The output of this example application will look like the one in the following screenshot:

Code example

Our library now allows us to easily add new strategies or new hashing algorithms and use them instantly. We don't have to change any of the existing classes.

The bridge design pattern the Scala way

The bridge design pattern is another example of those that can be achieved with the powerful features of the Scala programming language. Here we will be using self types. The initial Hasher trait remains unchanged. Then the actual implementations become traits instead of classes as follows:

trait Sha1Hasher extends Hasher {
  override def hash(data: String): String = new String(Hex.encodeHex(getDigest("SHA-1", data).digest()))
}

trait Sha256Hasher extends Hasher {
  override def hash(data: String): String = new String(Hex.encodeHex(getDigest("SHA-256", data).digest()))
}

trait Md5Hasher extends Hasher {
  override def hash(data: String): String = new String(Hex.encodeHex(getDigest("MD5", data).digest()))
}

Having traits would allow us to mix them in when needed later.

We've changed some names for this version of our example just to avoid confusion. The PasswordConverter (PasswordConverterBase in this case) abstraction now looks like the following:

abstract class PasswordConverterBase {
  self: Hasher =>
  
  def convert(password: String): String
}

This tells the compiler that when we use the PasswordConverterBase, we also need to have a Hasher mixed in. Then we change the converter implementation to the following:

class SimplePasswordConverterScala extends PasswordConverterBase {
  self: Hasher =>
  
  override def convert(password: String): String = hash(password)
} 

class SaltedPasswordConverterScala(salt: String) extends PasswordConverterBase {
  self: Hasher =>
  
  override def convert(password: String): String = hash(s"${salt}:${password}")
}

Finally, we can use our new implementations as follows:

object ScalaBridgeExample {
  def main(args: Array[String]): Unit = {
    val p1 = new SimplePasswordConverterScala with Sha256Hasher
    val p2 = new SimplePasswordConverterScala with Md5Hasher
    val p3 = new SaltedPasswordConverterScala("8jsdf32T^$%") with Sha1Hasher
    val p4 = new SaltedPasswordConverterScala("8jsdf32T^$%") with Sha256Hasher

    System.out.println(s"'password' in SHA-256 is: ${p1.convert("password")}")
    System.out.println(s"'1234567890' in MD5 is: ${p2.convert("1234567890")}")
    System.out.println(s"'password' in salted SHA-1 is: ${p3.convert("password")}")
    System.out.println(s"'password' in salted SHA-256 is: ${p4.convert("password")}")
  }
}

The output of this program will be identical to the original one. However, when we use our abstractions, we can mix in the hash algorithms we want to use. The benefits will become more obvious in the cases where we might have more implementations that we might want to combine together with hashing. Using mixins also looks more natural and is easier to understand.

What is it good for?

As we already said, the bridge design pattern is similar to the adapter. Here, however, we apply it when we design our applications. One obvious benefit of using it is that we don't end up with an exponential number of classes in our application, which could make the use and maintenance of the pattern pretty complicated. The separation of hierarchies allows us to independently extend them without affecting the other one.

What is it not so good for?

The bridge design pattern requires us to write some boilerplate. It could complicate the use of the library in terms of which implementation is exactly picked, and it might be a good idea to use the bridge design pattern together with some creational design patterns. All in all, it doesn't have any major drawbacks but the developer should be wise whether to use it or not depending on the current circumstances.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.141.19.185