Structural Advanced 8 min read

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
  1. What problem does the Bridge pattern solve in C#?
  2. How does the class explosion show up in real code?
  3. How does the Bridge structure look in .NET 10?
  4. When should you use Bridge instead of just composition?
  5. How does Bridge compare to Adapter and Strategy?
  6. What does a real C# / .NET 10 example look like?
  7. 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:

  1. Notification style × channel. As above. Three styles, three channels, expected to grow on both axes.
  2. 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.
  3. 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:

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.

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?
Adapter wraps an existing class so it fits a different interface — it solves a mismatch between two parties that already exist. Bridge is a design choice up front: you decide that two axes of variation should be split into separate hierarchies. Adapter is a fix; Bridge is a plan.
When does Bridge feel like overkill in modern C#?
When only one axis actually varies. If you have three notification styles but only one delivery channel, you do not need Bridge — a single class hierarchy with the channel inlined is fine. Bridge earns its keep when both axes have at least two variants today and at least one will keep growing.
How is Bridge different from the Strategy pattern?
Strategy plugs in one algorithm where one used to be hard-coded; the abstraction itself is a single class. Bridge is bigger: it splits an entire abstraction hierarchy away from an entire implementation hierarchy and lets both grow. Bridge often contains a Strategy as its implementation side.
Can dependency injection replace the Bridge pattern entirely?
DI replaces the wiring step but not the design decision. You still have to choose to split your 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.