How to Implement Bridge Pattern in C#: Step-by-Step Guide
When your class hierarchy starts branching in two independent directions -- say, different types of notifications and different delivery channels -- you're heading toward an explosion of subclasses. The bridge pattern in C# solves this by separating the abstraction from its implementation so both can evolve independently. Instead of creating a class for every combination of notification type and delivery method, you compose the two dimensions at runtime through a clean interface boundary.
The bridge pattern is one of the most underused structural design patterns, yet it's one of the most powerful once you see the problem it solves. It works alongside patterns like the adapter pattern and the decorator pattern, but its focus is different -- while the adapter pattern translates between incompatible interfaces, the bridge pattern prevents a combinatorial class explosion by decoupling two hierarchies that would otherwise be fused together.
In this step-by-step guide, we'll implement the bridge pattern in C# by building a notification system where notification types (urgent, scheduled) and delivery mechanisms (email, SMS, push) vary independently. By the end, you'll have working code covering implementor interfaces, concrete implementors, abstraction classes, refined abstractions, dependency injection wiring, and runtime switching.
Prerequisites
Before diving in, make sure you're comfortable with these fundamentals:
- C# interfaces and abstract classes: You'll define an implementor interface and an abstract class for the abstraction hierarchy. Understanding the difference between the two is essential.
- Composition over inheritance: The bridge pattern relies on holding a reference to an implementor rather than inheriting from it. This is what keeps the two hierarchies independent.
- Dependency injection basics: The later steps cover registering bridge pattern components with IServiceCollection. Familiarity with constructor injection will help.
- .NET 8 or later: The code examples use modern C# syntax. Any recent .NET SDK works.
Step 1: Define the Implementor Interface
The implementor interface sits on the "implementation" side of the bridge pattern. It defines the low-level operations that concrete implementations must provide. In the bridge pattern, this interface is deliberately narrow -- it represents how something gets done, not what gets done. That distinction is what separates the bridge pattern from a simple interface extraction.
For our notification system, the implementor handles the raw act of sending a message:
public interface IMessageSender
{
MessageDeliveryResult Send(
string recipient,
string subject,
string body);
}
public sealed class MessageDeliveryResult
{
public bool IsSuccess { get; }
public string DeliveryId { get; }
public string ErrorMessage { get; }
public MessageDeliveryResult(
bool isSuccess,
string deliveryId,
string errorMessage = "")
{
IsSuccess = isSuccess;
DeliveryId = deliveryId;
ErrorMessage = errorMessage;
}
}
Notice that IMessageSender doesn't know anything about notification types, urgency levels, or scheduling. It only knows how to deliver a message to a recipient. This is intentional. When you implement the bridge pattern in C#, the implementor interface should contain only the primitive operations that vary by delivery mechanism. Everything else belongs in the abstraction layer.
The MessageDeliveryResult gives us a clean return type that any concrete sender can populate. Whether the message goes out via email, SMS, or push notification, the result follows the same structure.
Step 2: Create Concrete Implementors
Concrete implementors provide the actual delivery logic for each channel. Each one implements IMessageSender with the specifics of its transport mechanism. When you implement the bridge pattern in C#, adding a new delivery channel means writing a new implementor -- nothing else in the system needs to change.
Here are three concrete implementors:
public class EmailSender : IMessageSender
{
public MessageDeliveryResult Send(
string recipient,
string subject,
string body)
{
Console.WriteLine(
$"[Email] Sending to '{recipient}' " +
$"with subject '{subject}'");
string deliveryId = Guid.NewGuid()
.ToString("N")[..8]
.ToUpperInvariant();
Console.WriteLine(
$"[Email] Delivered. ID: {deliveryId}");
return new MessageDeliveryResult(
isSuccess: true,
deliveryId: deliveryId);
}
}
public class SmsSender : IMessageSender
{
public MessageDeliveryResult Send(
string recipient,
string subject,
string body)
{
string condensed = string.IsNullOrWhiteSpace(subject)
? body
: $"{subject}: {body}";
if (condensed.Length > 160)
{
condensed = condensed[..157] + "...";
}
Console.WriteLine(
$"[SMS] Sending to '{recipient}': " +
$"{condensed}");
string deliveryId = Guid.NewGuid()
.ToString("N")[..8]
.ToUpperInvariant();
return new MessageDeliveryResult(
isSuccess: true,
deliveryId: deliveryId);
}
}
public class PushNotificationSender : IMessageSender
{
public MessageDeliveryResult Send(
string recipient,
string subject,
string body)
{
string title = string.IsNullOrWhiteSpace(subject)
? "Notification"
: subject;
Console.WriteLine(
$"[Push] Sending push to device " +
$"'{recipient}' -- Title: '{title}'");
string deliveryId = Guid.NewGuid()
.ToString("N")[..8]
.ToUpperInvariant();
return new MessageDeliveryResult(
isSuccess: true,
deliveryId: deliveryId);
}
}
Each implementor adapts the Send call to its channel's constraints. The SmsSender truncates long messages to 160 characters. The PushNotificationSender falls back to a default title when the subject is empty. The EmailSender sends the content as-is. These are real differences in how each channel works, and the bridge pattern keeps them isolated from the abstraction layer.
This is where the bridge pattern starts to pay off. Without it, you'd need separate classes like UrgentEmailNotification, UrgentSmsNotification, ScheduledEmailNotification, ScheduledSmsNotification -- and the list grows with every new combination. The bridge pattern eliminates that multiplication by letting you compose any notification type with any sender.
Step 3: Define the Abstraction Class
The abstraction sits on the other side of the bridge. It defines the high-level operations your application code uses -- the what, not the how. In the bridge pattern, the abstraction holds a reference to an IMessageSender and delegates the actual delivery to it. This is the bridge itself -- the composition link between the two hierarchies.
public abstract class Notification
{
protected IMessageSender MessageSender { get; }
protected Notification(IMessageSender messageSender)
{
MessageSender = messageSender
?? throw new ArgumentNullException(
nameof(messageSender));
}
public abstract MessageDeliveryResult Notify(
string recipient,
string message);
}
A few things to highlight about this design:
- The
Notificationclass is abstract. You don't instantiate it directly -- you create refined abstractions that define specific notification behaviors. - The
IMessageSenderis stored as a protected property so subclasses can use it, but it's not exposed publicly. Client code never interacts with the sender directly. - The constructor enforces that every notification must have a sender. There's no valid notification without a delivery mechanism.
- The
Notifymethod is the high-level operation. Subclasses decide what the notification looks like. The sender decides how it gets delivered.
This separation is the core of the bridge pattern in C#. The Notification hierarchy can grow independently -- add new notification types without touching senders. The IMessageSender hierarchy can grow independently -- add new channels without touching notifications. Neither side knows about the other's concrete classes.
Step 4: Create Refined Abstractions
Refined abstractions extend the base Notification class with specific behaviors. Each one defines what kind of notification it represents, while the bridge pattern ensures it can use any delivery channel. This is where you see the pattern's flexibility most clearly.
public class UrgentNotification : Notification
{
public UrgentNotification(
IMessageSender messageSender)
: base(messageSender)
{
}
public override MessageDeliveryResult Notify(
string recipient,
string message)
{
string urgentSubject = "[URGENT] Action Required";
string urgentBody =
$"** URGENT **: {message}" +
$"
This requires your immediate attention.";
Console.WriteLine(
$"[UrgentNotification] Preparing urgent " +
$"message for '{recipient}'...");
return MessageSender.Send(
recipient,
urgentSubject,
urgentBody);
}
}
public class ScheduledNotification : Notification
{
private readonly DateTime _scheduledTime;
public ScheduledNotification(
IMessageSender messageSender,
DateTime scheduledTime)
: base(messageSender)
{
_scheduledTime = scheduledTime;
}
public override MessageDeliveryResult Notify(
string recipient,
string message)
{
if (DateTime.UtcNow < _scheduledTime)
{
Console.WriteLine(
$"[ScheduledNotification] Not yet time. " +
$"Scheduled for {_scheduledTime:u}. " +
$"Skipping send.");
return new MessageDeliveryResult(
isSuccess: false,
deliveryId: string.Empty,
errorMessage:
$"Scheduled for {_scheduledTime:u}. " +
$"Not yet due.");
}
string subject = "Scheduled Update";
string body =
$"{message}" +
$"
(Originally scheduled for " +
$"{_scheduledTime:u})";
Console.WriteLine(
$"[ScheduledNotification] Schedule reached. " +
$"Sending to '{recipient}'...");
return MessageSender.Send(
recipient,
subject,
body);
}
}
Look at how different these two refined abstractions are. UrgentNotification prepends urgency markers and sends immediately. ScheduledNotification checks whether the scheduled time has arrived before sending -- and if it hasn't, it returns a result without calling the sender at all.
Both use whatever IMessageSender was injected. An UrgentNotification with an EmailSender sends urgent emails. Swap in a PushNotificationSender and the same UrgentNotification sends urgent push notifications. No new classes needed. That's the bridge pattern in C# working as intended.
This is fundamentally different from the strategy pattern. With the strategy pattern, you swap out algorithms behind a single interface. With the bridge pattern, you have two independent hierarchies -- abstractions and implementors -- that vary along different axes. The strategy pattern gives you one dimension of variation. The bridge pattern gives you two.
Step 5: Wire Up with Dependency Injection
For production use, you'll register your bridge pattern components with Microsoft.Extensions.DependencyInjection. This keeps your composition root clean and lets the rest of your application request notifications without knowing which sender is behind them.
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
// Register implementors
services.AddSingleton<EmailSender>();
services.AddSingleton<SmsSender>();
services.AddSingleton<PushNotificationSender>();
// Register a default IMessageSender
services.AddSingleton<IMessageSender, EmailSender>();
// Register notification abstractions
services.AddTransient<UrgentNotification>(sp =>
{
var sender = sp
.GetRequiredService<IMessageSender>();
return new UrgentNotification(sender);
});
services.AddTransient<ScheduledNotification>(sp =>
{
var sender = sp
.GetRequiredService<IMessageSender>();
return new ScheduledNotification(
sender,
DateTime.UtcNow.AddMinutes(30));
});
var provider = services.BuildServiceProvider();
If you need multiple senders available simultaneously, use keyed services to register them by name:
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddKeyedSingleton<IMessageSender>(
"email",
(_, _) => new EmailSender());
services.AddKeyedSingleton<IMessageSender>(
"sms",
(_, _) => new SmsSender());
services.AddKeyedSingleton<IMessageSender>(
"push",
(_, _) => new PushNotificationSender());
var provider = services.BuildServiceProvider();
This keyed registration approach pairs well with the bridge pattern because it lets you resolve specific senders by name while keeping the IMessageSender interface consistent. If you haven't worked with service registration before, the IServiceCollection beginner's guide covers the essentials. The underlying principle here is inversion of control -- your high-level notification classes depend on the IMessageSender abstraction, and the DI container decides which concrete sender to provide.
Step 6: Runtime Switching of Implementations
One of the bridge pattern's most practical benefits is the ability to swap implementors at runtime. Because the abstraction holds a reference to IMessageSender through composition, you can create notifications with different senders based on configuration, user preferences, or business rules -- all without modifying any existing classes.
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddKeyedSingleton<IMessageSender>(
"email",
(_, _) => new EmailSender());
services.AddKeyedSingleton<IMessageSender>(
"sms",
(_, _) => new SmsSender());
services.AddKeyedSingleton<IMessageSender>(
"push",
(_, _) => new PushNotificationSender());
var provider = services.BuildServiceProvider();
// Simulate choosing a channel based on user preference
string userPreference = "sms";
var sender = provider
.GetRequiredKeyedService<IMessageSender>(
userPreference);
var urgentViaSms = new UrgentNotification(sender);
MessageDeliveryResult result = urgentViaSms.Notify(
"+1-555-0199",
"Server CPU at 95%. Investigate immediately.");
Console.WriteLine(
$"Delivered: {result.IsSuccess}, " +
$"ID: {result.DeliveryId}");
Here's a more complete example showing how a service can switch senders dynamically based on business logic:
public class AlertService
{
private readonly IServiceProvider _serviceProvider;
public AlertService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider
?? throw new ArgumentNullException(
nameof(serviceProvider));
}
public MessageDeliveryResult SendAlert(
string recipient,
string message,
string channel,
bool isUrgent)
{
var sender = _serviceProvider
.GetRequiredKeyedService<IMessageSender>(
channel);
Notification notification = isUrgent
? new UrgentNotification(sender)
: new ScheduledNotification(
sender,
DateTime.UtcNow);
return notification.Notify(recipient, message);
}
}
The AlertService decides at runtime which sender and which notification type to use. The bridge pattern makes this trivial because neither the abstraction nor the implementor hierarchy is locked to the other. You can combine any notification type with any delivery channel, and adding a new channel or a new notification type requires zero changes to existing code.
Common Mistakes to Avoid
Even experienced developers run into these pitfalls when they first implement the bridge pattern in C#.
Confusing the bridge pattern with the adapter pattern: The adapter pattern makes two existing incompatible interfaces work together. The bridge pattern is a design-time decision to keep two hierarchies separate from the start. If you're wrapping an existing class you can't change, that's an adapter. If you're designing a system where two dimensions of variation need to evolve independently, that's a bridge.
Making the implementor interface too broad: The implementor should contain only the primitive operations that vary by implementation. If you push high-level behavior into IMessageSender, you blur the line between abstraction and implementor. The abstraction defines what to do. The implementor defines how to do one specific thing.
Skipping the abstraction layer: If you inject IMessageSender directly into your service classes and handle notification logic inline, you've lost the abstraction hierarchy. You might think you're using the bridge pattern because you have an interface, but without the Notification class hierarchy, you don't have a bridge. You have a simple interface with multiple implementations -- closer to the strategy pattern.
Creating a single "god" abstraction: If your Notification base class tries to handle every notification behavior, there's no reason for refined abstractions. The base class should define the contract and hold the bridge reference. Specific behaviors belong in subclasses like UrgentNotification and ScheduledNotification.
Overusing the bridge pattern: Not every interface extraction requires a full bridge pattern. If you only have one dimension of variation -- say, different message senders but only one way to send notifications -- a simple interface with dependency injection is enough. The bridge pattern earns its keep when you genuinely have two independent hierarchies that would otherwise create a combinatorial explosion of classes.
Frequently Asked Questions
What problem does the bridge pattern solve in C#?
The bridge pattern solves the problem of class explosion when you have two independent dimensions of variation. Without it, you'd need a separate class for every combination -- UrgentEmailNotification, UrgentSmsNotification, ScheduledEmailNotification, and so on. The bridge pattern in C# separates these two dimensions into independent hierarchies connected through composition, so adding a new type on either side requires only one new class instead of many.
How is the bridge pattern different from the adapter pattern?
The adapter pattern wraps an existing class to make its interface compatible with one your code expects. It's a fix applied after the fact. The bridge pattern is a deliberate design decision made upfront to separate abstraction from implementation. With an adapter, you already have two incompatible interfaces. With the bridge pattern in C#, you design the separation from the start so both sides can evolve independently.
When should I use the bridge pattern instead of the strategy pattern?
Use the strategy pattern when you have one context class that needs to swap algorithms at runtime. Use the bridge pattern when you have two class hierarchies that both need to vary independently. If your notification types and delivery channels both grow over time and shouldn't know about each other, that's a bridge. If you have one notification class that swaps its sending algorithm, that's a strategy.
Can I combine the bridge pattern with other design patterns?
Absolutely. The bridge pattern works well with several other patterns. You can use the decorator pattern to add cross-cutting behavior like logging or retry logic around your implementors. You can use the facade pattern to simplify access to a complex set of bridge-connected components. And factory methods work naturally for creating abstraction-implementor combinations without exposing the wiring details to client code.
How do I test bridge pattern components?
Test each side of the bridge independently. For implementors, write tests that verify EmailSender, SmsSender, and PushNotificationSender produce correct results for various inputs. For abstractions, inject a mock IMessageSender and verify that UrgentNotification and ScheduledNotification call Send with the expected arguments. Because the bridge pattern separates the two hierarchies, you can test them in isolation without needing every combination.
Does the bridge pattern add too much complexity for small projects?
It can. If you have one abstraction and one implementor, the bridge pattern introduces interfaces and abstract classes that don't pay for themselves. The pattern becomes valuable when you genuinely face two dimensions of variation -- different notification types and different delivery mechanisms. If you only have one dimension, a simple interface with multiple implementations is a cleaner solution. Apply the bridge pattern in C# when you see the class count growing multiplicatively, not before.
How does the bridge pattern relate to dependency injection in C#?
The bridge pattern and dependency injection complement each other naturally. The abstraction's constructor takes an IMessageSender, which is a perfect match for constructor injection. Your DI container -- through IServiceCollection -- decides which concrete sender to inject. This follows inversion of control: the abstraction depends on an interface, and the container provides the concrete implementation at runtime. The bridge pattern gives you the architecture, and DI gives you the wiring.

