Bridge Pattern in C#: Stop the 3x3 Class Explosion
Bridge pattern in C# / .NET 10: separate an abstraction (notification style) from its implementation (delivery channel) to keep N+M classes instead of N*M.
Table of contents
- What problem does the Bridge pattern solve in C#?
- How does the class explosion show up in real code?
- How does the Bridge structure look in .NET 10?
- When should you use Bridge instead of just composition?
- How does Bridge compare to Adapter and Strategy?
- What does a real C# / .NET 10 example look like?
- Where should you read next in this series?
Your shop sends notifications. Three styles — Urgent (order failed, stop the line), Info (order shipped), Marketing (we miss you). Three channels — email, SMS, Slack. The first developer wired them up the obvious way:
EmailUrgent SmsUrgent SlackUrgent
EmailInfo SmsInfo SlackInfo
EmailMarketing SmsMarketing SlackMarketing
Nine classes for two axes of variation. The day product asks for a fourth channel (Push) and a fourth style (Onboarding), the count goes to sixteen. Every new style requires writing every channel; every new channel requires writing every style. The diff for a "single feature" spans twelve files.
The Bridge pattern is the answer. Notification style and delivery channel are two independent axes; let each grow on its own hierarchy and combine them at runtime. In modern C# this is straight composition with DI — but recognising the shape before you have nine classes is what saves the codebase.
What problem does the Bridge pattern solve in C#?
The pattern earns its place when a class has been growing along two orthogonal axes and the cross-product is becoming unmanageable. Three concrete shapes:
- Notification style × channel. As above. Three styles, three channels, expected to grow on both axes.
- Renderer style × document type. A reporting service emits PDF, HTML, and JSON; each in summary or detailed form. Six renderers today, twelve when CSV arrives, eighteen with executive style.
- UI theme × control kit. Light/dark crossed with desktop/mobile crossed with high-contrast. Without Bridge, every new theme multiplies the work by the number of control kits.
What is not a Bridge problem: "I have one class and want to swap its algorithm" — that is Strategy. "I have one external SDK that does not match my interface" — that is Adapter. Bridge is specifically about two hierarchies that should be allowed to vary independently.
How does the class explosion show up in real code?
The smell is hard to spot at three classes — EmailUrgent,
SmsUrgent, SlackUrgent look fine. It becomes obvious at six and
unbearable at nine. A faster diagnostic: open any one class and ask
"what does it actually do?". If the answer is "it formats an urgent
message and sends it over email", you have two responsibilities
welded together. The word "and" in the description is the symptom.
Each class duplicates parts of two stories:
// Before — every class duplicates style + channel logic
public sealed class EmailUrgent
{
private readonly SmtpClient _smtp;
public Task SendAsync(string to, string body)
{
var subject = "🚨 URGENT — action required"; // style logic
var html = $"<h1>{subject}</h1><p>{body}</p>"; // style logic
return _smtp.SendAsync(to, subject, html); // channel logic
}
}
public sealed class SmsUrgent
{
private readonly TwilioClient _twilio;
public Task SendAsync(string to, string body)
{
var prefix = "URGENT: "; // duplicated style logic
return _twilio.SendAsync(to, prefix + body); // channel logic
}
}
The "URGENT" decoration logic now lives in two places. When marketing asks to add a sirens emoji to every urgent message, you visit every class in the urgent column.
How does the Bridge structure look in .NET 10?
Split the two responsibilities along the seam between style and channel. Style becomes an abstraction that holds a channel; channel becomes an implementation interface:
// Implementation side (channel — knows how to deliver)
public interface INotificationChannel
{
Task DeliverAsync(string recipient, string subject, string body, Priority priority,
CancellationToken ct);
}
public sealed class EmailChannel : INotificationChannel { /* SMTP */ }
public sealed class SmsChannel : INotificationChannel { /* Twilio */ }
public sealed class SlackChannel : INotificationChannel { /* Slack webhook */ }
// Abstraction side (style — knows what to say)
public abstract class Notification
{
protected INotificationChannel Channel { get; }
protected Notification(INotificationChannel channel) => Channel = channel;
public abstract Task SendAsync(string recipient, string raw, CancellationToken ct);
}
public sealed class UrgentNotification : Notification
{
public UrgentNotification(INotificationChannel ch) : base(ch) { }
public override Task SendAsync(string to, string raw, CancellationToken ct)
=> Channel.DeliverAsync(to, "🚨 URGENT — action required",
$"URGENT: {raw}", Priority.High, ct);
}
public sealed class InfoNotification : Notification
{
public InfoNotification(INotificationChannel ch) : base(ch) { }
public override Task SendAsync(string to, string raw, CancellationToken ct)
=> Channel.DeliverAsync(to, "Update", raw, Priority.Normal, ct);
}
public sealed class MarketingNotification : Notification
{
public MarketingNotification(INotificationChannel ch) : base(ch) { }
public override Task SendAsync(string to, string raw, CancellationToken ct)
=> Channel.DeliverAsync(to, "We thought you'd like to know…",
raw + "\n\nUnsubscribe: …", Priority.Low, ct);
}
The class count drops from nine to six (three styles + three channels). Adding a fourth style is one new class plus zero edits to channels. Adding a fourth channel is one new class plus zero edits to styles. The structural picture:
classDiagram
class Notification {
<<abstract>>
#INotificationChannel Channel
+SendAsync(to, raw)
}
class UrgentNotification
class InfoNotification
class MarketingNotification
Notification <|-- UrgentNotification
Notification <|-- InfoNotification
Notification <|-- MarketingNotification
class INotificationChannel {
<<interface>>
+DeliverAsync(to, subject, body, priority)
}
class EmailChannel
class SmsChannel
class SlackChannel
INotificationChannel <|.. EmailChannel
INotificationChannel <|.. SmsChannel
INotificationChannel <|.. SlackChannel
Notification o-- INotificationChannel : holds
Two hierarchies that grow on their own.
When should you use Bridge instead of just composition?
Bridge is a specific shape of composition. The decision rule:
- Use Bridge when the two axes are first-class concepts in your domain that callers will need to talk about (notification style, delivery channel) and both axes are expected to grow.
- Use plain composition when one of the two axes is a single
concrete dependency — like every notification using the same
IClockfor timestamps. There is no second hierarchy; injecting the dependency is enough. - Use Strategy when one class needs to swap behaviour but the structure does not split into a hierarchy.
A failure case that always reveals an over-application of Bridge: when the abstraction side has only one concrete subclass for months on end, the split was speculative. Collapse the abstraction back into the one that exists. Re-split the day a second style genuinely arrives.
How does Bridge compare to Adapter and Strategy?
The patterns sit close to each other but answer different questions:
| Pattern | Question it answers | Number of hierarchies | Modern .NET shape |
|---|---|---|---|
| Bridge | How do two orthogonal axes grow without N×M classes? | Two parallel hierarchies | Composition + DI |
| Adapter | How does a foreign class fit my interface? | One wrapping the other | Wrapper class |
| Strategy | How does one class swap one algorithm? | None (one class, one interface) | Injected IStrategy or Func<> |
The cleanest rule: Bridge is for splitting; Adapter is for fitting; Strategy is for swapping. If your codebase already has the split (two hierarchies that should compose) and you are designing it now, you are doing Bridge. If the split exists but you only need to plug in one piece on one axis, that is Strategy.
What does a real C# / .NET 10 example look like?
A complete shape that ships in production. Notice the DI registration: each axis is a separate concern; consumers ask for the combination they want via a small factory or by name.
public enum Priority { Low, Normal, High }
public interface INotificationChannel
{
Task DeliverAsync(string recipient, string subject, string body,
Priority priority, CancellationToken ct);
}
public sealed class EmailChannel : INotificationChannel
{
private readonly SmtpClient _smtp;
public EmailChannel(SmtpClient smtp) => _smtp = smtp;
public Task DeliverAsync(string to, string subject, string body, Priority p, CancellationToken ct)
=> _smtp.SendMailAsync(new MailMessage("noreply@shop.example", to, subject, body), ct);
}
public sealed class SmsChannel : INotificationChannel
{
private readonly ITwilioClient _twilio;
public SmsChannel(ITwilioClient twilio) => _twilio = twilio;
public Task DeliverAsync(string to, string subject, string body, Priority p, CancellationToken ct)
=> _twilio.SendMessageAsync(to, body, ct); // SMS ignores subject
}
public abstract class Notification
{
protected INotificationChannel Channel { get; }
protected Notification(INotificationChannel channel) => Channel = channel;
public abstract Task SendAsync(string recipient, string raw, CancellationToken ct);
}
public sealed class UrgentNotification : Notification
{
public UrgentNotification(INotificationChannel ch) : base(ch) { }
public override Task SendAsync(string to, string raw, CancellationToken ct)
=> Channel.DeliverAsync(to, "🚨 URGENT", $"URGENT: {raw}", Priority.High, ct);
}
public sealed class InfoNotification : Notification
{
public InfoNotification(INotificationChannel ch) : base(ch) { }
public override Task SendAsync(string to, string raw, CancellationToken ct)
=> Channel.DeliverAsync(to, "Update", raw, Priority.Normal, ct);
}
// Composition root ------------------------------------------------
builder.Services.AddSingleton<SmtpClient>();
builder.Services.AddSingleton<ITwilioClient, TwilioClient>();
builder.Services.AddKeyedScoped<INotificationChannel, EmailChannel>("email");
builder.Services.AddKeyedScoped<INotificationChannel, SmsChannel>("sms");
// Combine style + channel via a tiny factory at the boundary
public sealed class NotificationFactory
{
private readonly IKeyedServiceProvider _sp;
public NotificationFactory(IKeyedServiceProvider sp) => _sp = sp;
public Notification Create(string style, string channelKey)
{
var channel = _sp.GetRequiredKeyedService<INotificationChannel>(channelKey);
return style switch
{
"urgent" => new UrgentNotification(channel),
"info" => new InfoNotification(channel),
_ => throw new ArgumentException($"Unknown style: {style}")
};
}
}
// Caller
var ship = factory.Create("info", "email");
await ship.SendAsync(buyer.Email, $"Order #{order.Id} shipped", ct);
var alert = factory.Create("urgent", "sms");
await alert.SendAsync(buyer.Phone, "Payment failed — please retry", ct);
What you do not see: nine classes named EmailUrgent, SmsUrgent,
etc. The matrix has collapsed into two hierarchies of three each. Add
a Push channel and an Onboarding style next quarter — that is two
new files, not eight.
Where should you read next in this series?
- Previous: Adapter — when an existing class has the wrong interface and you cannot redesign it.
- Next: Composite — when the same operations apply to single items and groups of items.
- Cross-reference: Strategy — when you need to swap one algorithm, not split two hierarchies.
- Cross-reference: Factory Method —
the
NotificationFactoryabove is exactly that pattern, used to combine the two Bridge axes. - Decision tree: How to choose the right design pattern.
A thinking habit that prevents future Bridge cleanups: every time you
are about to write new ThingForXForY (a class name with two
qualifiers in it), pause. There is a Bridge waiting to happen. Most of
the time the right code is new XThing(yImplementation) — composition
of two hierarchies, not a third class for the cross product.
Frequently asked questions
What is the difference between Bridge and Adapter?
When does Bridge feel like overkill in modern C#?
How is Bridge different from the Strategy pattern?
Can dependency injection replace the Bridge pattern entirely?
EmailUrgentNotification class into a UrgentNotification and an EmailChannel. Once you have made that split, DI is the natural way to combine them at runtime — services.AddScoped<INotificationChannel, EmailChannel>() plus services.AddTransient<UrgentNotification>() and the constructor handles the rest.