In this article, we are going to talk about a structural design pattern, the Bridge Pattern. We will discuss what problem this pattern solves and how to implement it in C#.
If you want to read more about design patterns in C#, you can inspect our C# Design Patterns page.
Let’s start.
What is The Bridge Design Pattern?
By definition, the Bridge Design Pattern is a structural pattern that helps us decouple a class’s implementation from its abstraction so the two can vary independently.
No surprise, this obscure definition demands further explanation, but let’s first take a look at the structure we are talking about:
Generally, the term “abstraction” refers to an interface (or abstract class) construct, and the “implementation” refers to concrete sub-classes. However, they convey different meanings in the context of the bridge pattern.
The Abstraction defines the top-level control layer of the feature and serves as an entry point from the client code. It can be either an interface or an abstract class. In other words, the Abstraction represents the hierarchy of the control layer.
Each RefinedAbstraction
is a concrete sub-class that implements the Abstraction
interface. Such classes may exhibit different strategies as a top-level workflow, but they all communicate to the implementation layer through the Implementor interface.
The Implementor defines an implementation part of the feature that can be developed independently and acts as a connecting point from the Abstraction hierarchy. It can be either an interface or an abstract class. In other words, the Implementor represents the hierarchy of a designated implementation layer.
ConcreteImplementor
represents a concrete sub-class of the Implementor
interface and holds the actual business implementation.
To sum up, the bridge pattern offers a means to bridge between the Abstraction and Implementor hierarchies.
What Problem Does Bridge Pattern Solve?
Let’s take an example of an order-pricing calculator:
public abstract class PriceCalculator { public decimal Calculate(decimal basePrice) { var deliveryFee = GetDeliveryFee(); var discount = GetDiscount(basePrice); return basePrice + deliveryFee - discount; } protected abstract decimal GetDeliveryFee(); protected abstract decimal GetDiscount(decimal price); }
We aim to provide a Calculate()
method that takes on a base price parameter and returns the calculated total price. The calculation is subjected to different discount options and delivery fees.
So our abstract calculator class provides the top-level workflow for the Calculate()
method, and exposes two extensible points for the sub-classes. That means the sub-classes can bring refinements in two directions by implementing GetDeliveryFee()
and GetDiscount()
methods.
Let’s consider two such concrete implementations that deal with different delivery fees.
The StandardPriceCalculator
:
public class StandardPriceCalculator : PriceCalculator { protected override decimal GetDeliveryFee() => 2.5m; protected override decimal GetDiscount(decimal price) => 0; }
The ExpeditedPriceCalculator
:
public class ExpeditedPriceCalculator : PriceCalculator { protected override decimal GetDeliveryFee() => 5m; protected override decimal GetDiscount(decimal price) => 0; }
The expedited calculator imposes a double delivery fee compared to the standard calculator. Also, none of them provides any discount.
On top of this, we want to introduce a promotional discount for some orders. Since existing no-discount calculators are still valid for other orders, we need new calculators:
public class PromoCodeStandardPriceCalculator(PromoCode promoCode) : StandardPriceCalculator { protected override decimal GetDiscount(decimal price) { var factor = promoCode switch { PromoCode.Free5 => 0.05m, PromoCode.Free10 => 0.10m, _ => throw new NotImplementedException() }; return price * factor; } }
At its core, the promotion introduces a business logic to calculate the discount amount based on a supplied promo code. However, since we also have multiple delivery modes, and our design is built on inheritance, we need two variants – PromoCodeStandardPriceCalculator
and PromoCodeExpeditedPriceCalculator
, inherited from the previous standard and expedited calculators respectively.
One obvious problem with this approach is the duplication of discount logic in both new calculators. Another problem is the magnitude of different combinations, leading to an explosion of sub-classes. While the first problem can be mitigated to some extent by introducing a shared helper class, the second problem is a real deal.
To better understand the second problem, let’s add one more delivery mode (Express
) and discount scheme (HotSale
) to the mix:
As the illustration hints, the number of sub-classes rises exponentially with the increase of options in each varying factor.
In our case, the sub-class count comprises the number of delivery options multiplied by the number of discount schemes. So we ended up with 9 subclasses.
This problem is common when an inheritance-based structure tries to evolve in multiple directions. A design like this soon becomes a maintenance menace and a potential source of side effects.
The bridge pattern effectively addresses this problem by choosing composition over inheritance. It offers us a way to split a monolithic hierarchy into simpler hierarchies and lets us extend each hierarchy orthogonally.
Bridge Pattern Implementation in C#
So, let’s fix our calculator design by applying the bridge pattern.
Our primary goal is to abstract away discount logic into a separate hierarchy:
public interface IDiscount { decimal GetDiscount(decimal price); }
The IDiscount
interface is the face of this hierarchy.
Next, let’s transfer the discount logic from the calculators to standalone classes under the umbrella of IDiscount
hierarchy.
The NoDiscount
class:
public class NoDiscount : IDiscount { public decimal GetDiscount(decimal price) => 0; }
The PromoCodeDiscount
class:
public class PromoCodeDiscount(PromoCode promoCode) : IDiscount { public decimal GetDiscount(decimal price) { var factor = promoCode switch { PromoCode.Free5 => 0.05m, PromoCode.Free10 => 0.10m, _ => throw new NotImplementedException() }; return price * factor; } }
And the HotSaleDiscount
class:
public class HotSaleDiscount : IDiscount { public decimal GetDiscount(decimal price) => price > 50 ? 20 : 0; }
The Implementor end of the bridge is ready, let’s take a look at the whole bridge plan:
Now the definition of the bridge pattern seems to make sense! But we are not done yet, the Abstraction end also needs refactorings.
First, let’s eliminate all combo variants of the calculator classes (PromoCod…, HotSale…), leaving only the basic implementation for each delivery mode. Once that is done, we will only have StandardPriceCalculator
, ExpeditedPriceCalculator
, and ExpressPriceCalculator
.
Next, in order to make the bridging possible, we have to introduce the IDiscount
interface as a dependency of calculators:
public abstract class PriceCalculator(IDiscount discount) { public decimal Calculate(decimal basePrice) { var deliveryFee = GetDeliveryFee(); var discountAmount = discount.GetDiscount(basePrice); return basePrice + deliveryFee - discountAmount; } protected abstract decimal GetDeliveryFee(); }
We do so by accepting the discount
as a constructor parameter of the base calculator class. This eventually allows us to call the discount.GetDiscount()
method in place of the previous GetDiscount()
method.
The Abstraction root is ready. Let’s wrap up the refactoring by propagating the constructor dependency to all descendant calculator classes:
public class StandardPriceCalculator(IDiscount discount) : PriceCalculator(discount) { protected override decimal GetDeliveryFee() => 2.5m; }
So we successfully decouple the discount model from the calculator model.
That means we can pair up a calculator with any variant of the discount model on demand:
var productPrice = 100m; var standard = new StandardPriceCalculator(new NoDiscount()) .Calculate(productPrice); Console.WriteLine($"Standard: {standard}"); // 102.5 var standardPromo = new StandardPriceCalculator(new PromoCodeDiscount(PromoCode.Free10)) .Calculate(productPrice); Console.WriteLine($"Standard Promo: {standardPromo}"); // 92.5 var standardHotSale = new StandardPriceCalculator(new HotSaleDiscount()) .Calculate(productPrice); Console.WriteLine($"Standard HotSale: {standardHotSale}"); // 82.5
As we see, in order to get the price of a standard delivery, we don’t need to look around for many different versions of the standard calculator. We only need to compose the calculator instance with our chosen discount model.
Pattern Caveats
Undoubtedly, the Bridge pattern is a powerful technique that helps us design highly decoupled structures to mitigate problems we face in a complex monolithic class. That said, there are certain factors that we should be aware of.
The biggest concern with the bridge pattern is the complexity. It’s crucial to identify the independent extensible points and their scope boundaries. This demands a greater understanding of the overall features and forecast for further expansion.
While composition-based workflow allows us to combine the components on demand, it also shifts the responsibility from internal mechanisms toward the client code. This may lead to incompatible pairings and unexpected behavior if not carefully designed.
In a nutshell, we should carefully assess the worth of bridging between hierarchies, otherwise, we may end up with an unnecessarily convoluted design.
Conclusion
In this article, we have learned how to use the Bridge Design pattern in a C# application. We have demonstrated how this pattern can help us solve particular problems. We have also discussed some factors we should consider while using such patterns.