Why the Bridge Pattern Matters
If you've ever found yourself creating an explosion of subclasses just to handle different combinations of features, the Bridge pattern offers a cleaner solution. When you need to vary both what an object does and how it does it, inheritance alone forces you into a rigid hierarchy that becomes unmanageable as complexity grows.
The Bridge pattern separates abstraction from implementation, letting each evolve independently. Instead of creating CircleOnWindows, CircleOnLinux, SquareOnWindows, and SquareOnLinux classes, you build one hierarchy for shapes and another for rendering platforms. The abstraction delegates to the implementation through an interface, breaking the tight coupling that makes systems brittle.
You'll learn how to identify when Bridge fits your design, implement the pattern with clean separation, and build systems where abstractions and implementations can change without cascading rewrites across your codebase.
The Problem Without Bridge
Consider a notification system that supports different message types and delivery channels. Without the Bridge pattern, you might create a class for every combination: EmailTextNotification, EmailHtmlNotification, SmsTextNotification, SmsHtmlNotification, and so on. Each new message format or channel multiplies your classes exponentially.
This combinatorial explosion makes the codebase fragile. Adding a new delivery channel means creating classes for every message format. Adding a new format means creating classes for every channel. The duplication grows quadratically, and shared logic gets scattered across dozens of similar classes.
// Without Bridge: combinatorial explosion
public class EmailTextNotification
{
public void Send(string recipient, string message)
{
var plainText = message;
SendViaEmail(recipient, plainText);
}
private void SendViaEmail(string to, string content) { /* email logic */ }
}
public class EmailHtmlNotification
{
public void Send(string recipient, string message)
{
var html = $"<html><body>{message}</body></html>";
SendViaEmail(recipient, html);
}
private void SendViaEmail(string to, string content) { /* email logic */ }
}
public class SmsTextNotification
{
public void Send(string recipient, string message)
{
var plainText = message;
SendViaSms(recipient, plainText);
}
private void SendViaSms(string to, string content) { /* SMS logic */ }
}
// This pattern continues for every format × channel combination
Notice how the email-sending logic duplicates across EmailTextNotification and EmailHtmlNotification. Similarly, formatting logic for plain text appears in both EmailTextNotification and SmsTextNotification. Each class mixes abstraction concerns (what message to send) with implementation concerns (how to deliver it), violating the Single Responsibility Principle and making maintenance painful.
Implementing the Bridge Pattern
The Bridge pattern separates these dimensions into two hierarchies. The abstraction hierarchy represents what you're doing (sending different message types), while the implementation hierarchy represents how you do it (delivery channels). The abstraction holds a reference to the implementation interface and delegates the actual work to it.
This separation means you can add new message types without touching delivery channels, and add new channels without modifying message types. Each hierarchy evolves independently, connected only through a stable interface.
// Implementation interface (how to send)
public interface IMessageSender
{
void SendMessage(string recipient, string content);
}
// Concrete implementations (delivery channels)
public class EmailSender : IMessageSender
{
public void SendMessage(string recipient, string content)
{
Console.WriteLine($"Sending email to {recipient}:");
Console.WriteLine(content);
// Actual email sending logic here
}
}
public class SmsSender : IMessageSender
{
public void SendMessage(string recipient, string content)
{
Console.WriteLine($"Sending SMS to {recipient}:");
Console.WriteLine(content.Length > 160
? content.Substring(0, 160)
: content);
// Actual SMS sending logic here
}
}
// Abstraction (what to send)
public abstract class Notification
{
protected IMessageSender _sender;
protected Notification(IMessageSender sender)
{
_sender = sender;
}
public abstract void Send(string recipient, string message);
}
// Refined abstractions (message types)
public class TextNotification : Notification
{
public TextNotification(IMessageSender sender) : base(sender) { }
public override void Send(string recipient, string message)
{
_sender.SendMessage(recipient, message);
}
}
public class HtmlNotification : Notification
{
public HtmlNotification(IMessageSender sender) : base(sender) { }
public override void Send(string recipient, string message)
{
var html = $"<html><body>{message}</body></html>";
_sender.SendMessage(recipient, html);
}
}
The Notification abstraction doesn't know or care how messages get delivered. It only knows it has an IMessageSender that can send content. Each refined abstraction (TextNotification, HtmlNotification) handles its specific formatting, then delegates delivery to whatever sender was injected. This clean separation means adding a PushNotificationSender or MarkdownNotification requires no changes to existing classes.
Real-World Bridge Applications
The Bridge pattern shines in scenarios where you have two orthogonal dimensions of variation. UI toolkits commonly use Bridge to separate window abstractions (Dialog, Button, Scrollbar) from platform implementations (Windows, macOS, Linux). The abstraction layer defines consistent behavior, while platform-specific implementations handle OS details.
Database access layers also benefit from Bridge. You might have different query abstractions (SimpleQuery, JoinQuery, AggregateQuery) and different database implementations (SqlServerConnection, PostgresConnection, MongoConnection). The query abstractions remain stable while you swap database implementations without rewriting query logic.
// Implementation interface
public interface IDatabaseConnection
{
string ExecuteQuery(string query);
int ExecuteNonQuery(string command);
}
// Concrete implementations
public class SqlServerConnection : IDatabaseConnection
{
private readonly string _connectionString;
public SqlServerConnection(string connectionString)
{
_connectionString = connectionString;
}
public string ExecuteQuery(string query)
{
// SQL Server-specific execution
return $"SqlServer result for: {query}";
}
public int ExecuteNonQuery(string command)
{
// SQL Server-specific execution
return 1;
}
}
public class PostgresConnection : IDatabaseConnection
{
private readonly string _connectionString;
public PostgresConnection(string connectionString)
{
_connectionString = connectionString;
}
public string ExecuteQuery(string query)
{
// PostgreSQL-specific execution
return $"Postgres result for: {query}";
}
public int ExecuteNonQuery(string command)
{
// PostgreSQL-specific execution
return 1;
}
}
// Abstraction
public abstract class DataQuery
{
protected IDatabaseConnection _connection;
protected DataQuery(IDatabaseConnection connection)
{
_connection = connection;
}
public abstract string Execute();
}
// Refined abstractions
public class CustomerQuery : DataQuery
{
private readonly int _customerId;
public CustomerQuery(IDatabaseConnection connection, int customerId)
: base(connection)
{
_customerId = customerId;
}
public override string Execute()
{
var query = $"SELECT * FROM Customers WHERE Id = {_customerId}";
return _connection.ExecuteQuery(query);
}
}
public class OrderAggregateQuery : DataQuery
{
private readonly DateTime _startDate;
public OrderAggregateQuery(IDatabaseConnection connection, DateTime startDate)
: base(connection)
{
_startDate = startDate;
}
public override string Execute()
{
var query = $"SELECT COUNT(*), SUM(Total) FROM Orders " +
$"WHERE OrderDate >= '{_startDate:yyyy-MM-dd}'";
return _connection.ExecuteQuery(query);
}
}
This design lets you run the same query abstraction against different databases by simply swapping the connection implementation. Testing becomes easier because you can inject a mock connection without modifying query classes. The abstraction and implementation hierarchies remain loosely coupled, connected only through the IDatabaseConnection interface.
Try It Yourself
Build a working notification system that demonstrates the Bridge pattern's flexibility. You'll create text and urgent notifications that can be sent via email or SMS, then easily swap implementations at runtime.
Steps
- Create a new console project:
dotnet new console -n BridgeDemo
- Navigate to the project:
cd BridgeDemo
- Replace the contents of Program.cs with the code below
- Update BridgeDemo.csproj as shown
- Execute with:
dotnet run
// Implementation interface
interface IMessageSender
{
void SendMessage(string recipient, string content);
}
// Concrete implementations
class EmailSender : IMessageSender
{
public void SendMessage(string recipient, string content)
{
Console.WriteLine($"\n[EMAIL] To: {recipient}");
Console.WriteLine($"Content: {content}");
}
}
class SmsSender : IMessageSender
{
public void SendMessage(string recipient, string content)
{
Console.WriteLine($"\n[SMS] To: {recipient}");
Console.WriteLine($"Content: {content.Substring(0, Math.Min(160, content.Length))}");
}
}
// Abstraction
abstract class Notification
{
protected IMessageSender _sender;
protected Notification(IMessageSender sender) => _sender = sender;
public abstract void Send(string recipient, string message);
}
// Refined abstractions
class TextNotification : Notification
{
public TextNotification(IMessageSender sender) : base(sender) { }
public override void Send(string recipient, string message)
=> _sender.SendMessage(recipient, message);
}
class UrgentNotification : Notification
{
public UrgentNotification(IMessageSender sender) : base(sender) { }
public override void Send(string recipient, string message)
=> _sender.SendMessage(recipient, $"URGENT: {message}");
}
// Demo
var emailSender = new EmailSender();
var smsSender = new SmsSender();
var textEmail = new TextNotification(emailSender);
var urgentSms = new UrgentNotification(smsSender);
var urgentEmail = new UrgentNotification(emailSender);
textEmail.Send("user@example.com", "Your order has shipped");
urgentSms.Send("+1-555-0100", "Server down! Immediate action required");
urgentEmail.Send("admin@example.com", "Critical security alert detected");
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Expected Output
[EMAIL] To: user@example.com
Content: Your order has shipped
[SMS] To: +1-555-0100
Content: URGENT: Server down! Immediate action required
[EMAIL] To: admin@example.com
Content: URGENT: Critical security alert detected
Design Trade-offs and Alternatives
Choose Bridge when you have two dimensions that vary independently and you need runtime flexibility. You gain the ability to mix and match abstractions with implementations without creating combinatorial classes. The cost is additional indirection through the implementation interface, which adds a layer of abstraction that simpler problems don't need.
Choose simple inheritance when you have only one dimension of variation or when abstraction and implementation never change independently. If you're building a shape hierarchy with no platform-specific rendering concerns, inheritance alone suffices. Bridge's extra structure only pays off when you actually need to vary both dimensions.
Consider Strategy pattern when you're varying algorithms within a single class rather than structuring entire hierarchies. Strategy focuses on swapping behavior in one context, while Bridge separates two orthogonal hierarchies. If you only need to change how one operation works, Strategy is simpler. If you're structuring multiple related operations across platforms, Bridge fits better.
If unsure, start with the simplest design that works and refactor to Bridge when you add a second dimension of variation. Monitor whether you're creating classes that only differ in implementation details. When you find yourself writing CustomerQueryForSqlServer and CustomerQueryForPostgres, that's your signal to introduce Bridge and separate the query abstraction from the database implementation.