Bridge — Decouple Abstraction from Implementation
The Bridge pattern in C#: separate an abstraction from its implementation so both can vary independently. Practical examples with notification channels and database providers.
Bridge — Decouple Abstraction from Implementation
Bridge separates an abstraction from its implementation so both can vary independently. Where Adapter makes incompatible interfaces work together, Bridge is designed upfront to prevent an explosion of subclasses.
The Problem Without Bridge
// Without Bridge: subclass explosion
// Add one new notification type → need new class for every channel
class EmailOrderNotification { }
class SmsOrderNotification { }
class PushOrderNotification { }
class EmailShipmentNotification { }
class SmsShipmentNotification { }
class PushShipmentNotification { }
// N notification types × M channels = N×M classesBridge Implementation
// IMPLEMENTATION side — how to send a message
public interface IMessageChannel
{
Task SendAsync(string recipient, string subject, string body);
}
public class EmailChannel : IMessageChannel
{
public async Task SendAsync(string recipient, string subject, string body)
{
Console.WriteLine($"[Email → {recipient}] {subject}: {body}");
// real SMTP logic here
}
}
public class SmsChannel : IMessageChannel
{
public async Task SendAsync(string recipient, string subject, string body)
{
Console.WriteLine($"[SMS → {recipient}] {body}"); // SMS ignores subject
}
}
public class PushChannel : IMessageChannel
{
public async Task SendAsync(string recipient, string subject, string body)
{
Console.WriteLine($"[Push → {recipient}] {subject}"); // Push shows title only
}
}
// ABSTRACTION side — what message to compose
public abstract class Notification(IMessageChannel channel)
{
protected IMessageChannel Channel { get; } = channel;
public abstract Task SendAsync(string recipient);
}
public class OrderConfirmationNotification(IMessageChannel channel, int orderId, decimal total)
: Notification(channel)
{
public override Task SendAsync(string recipient)
=> Channel.SendAsync(
recipient,
subject: "Order Confirmed",
body: $"Your order #{orderId} for £{total:F2} has been confirmed."
);
}
public class ShipmentNotification(IMessageChannel channel, string trackingCode)
: Notification(channel)
{
public override Task SendAsync(string recipient)
=> Channel.SendAsync(
recipient,
subject: "Your order has shipped",
body: $"Track your parcel: {trackingCode}"
);
}
// Mix and match — N + M classes instead of N × M
var emailOrder = new OrderConfirmationNotification(new EmailChannel(), 42, 99.99m);
var smsShip = new ShipmentNotification(new SmsChannel(), "TRK-001");
await emailOrder.SendAsync("alice@example.com");
await smsShip.SendAsync("+44 7700 000000");Bridge with Dependency Injection
// Register channels as named services
builder.Services.AddKeyedScoped<IMessageChannel, EmailChannel>("email");
builder.Services.AddKeyedScoped<IMessageChannel, SmsChannel>("sms");
builder.Services.AddKeyedScoped<IMessageChannel, PushChannel>("push");
// Notification factory selects channel at runtime
public class NotificationDispatcher(IServiceProvider sp)
{
public async Task DispatchAsync(NotificationRequest req)
{
var channel = sp.GetRequiredKeyedService<IMessageChannel>(req.Channel);
Notification notification = req.Type switch
{
"order" => new OrderConfirmationNotification(channel, req.OrderId, req.Total),
"shipment" => new ShipmentNotification(channel, req.TrackingCode),
_ => throw new ArgumentException($"Unknown notification type: {req.Type}"),
};
await notification.SendAsync(req.Recipient);
}
}Bridge vs Adapter vs Strategy
Adapter: fixes a design mismatch — incompatible interfaces made compatible post-hoc
Bridge: designed upfront to separate two dimensions that both need to vary
Strategy: replaces a single algorithm — one abstraction, many implementations (simpler)
Use Bridge when: you have two orthogonal axes of variation
(e.g., notification type × delivery channel)
(e.g., shape type × rendering engine)
(e.g., query type × database backend)Interview Answer
"The Bridge pattern separates an abstraction from its implementation so both can evolve independently — solving the 'cartesian product' subclass explosion problem. The classic example: if you have N notification types and M delivery channels, inheritance gives N×M subclasses; Bridge gives N + M by holding a reference to the channel (implementation) inside the notification (abstraction). In .NET, Bridge appears naturally when using DI:
Notificationclasses takeIMessageChannelas a constructor parameter — the DI container bridges the abstraction (what to send) and the implementation (how to deliver). Unlike Adapter (which fixes an existing mismatch), Bridge is designed intentionally to allow both dimensions to grow without touching existing code."
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.