Bridge Pattern: Letting Abstraction and Implementation Grow Independently
Bridge splits two independent axes of change (like message type and channel) into two hierarchies connected by a reference, instead of squeezing them into one inheritance hierarchy, so the class count becomes a sum rather than a product. Moving Bookstore's notifications from a 3 × 3 class explosion to 3 + 3, the HttpClient/HttpMessageHandler and ILogger/ILoggerProvider bridges and how it differs from Adapter.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Bookstore sends its customers three kinds of notifications: an order-shipped alert, a daily stock report for the warehouse team and a one-time sign-in code, an OTP (One-Time Password). Each can go out through three channels: email, SMS (Short Message Service) and mobile push. The first developer wrote every combination as a separate class. Last month the security team cut the OTP's validity from 5 minutes to 3, and a developer made the change:
Notification[] otps =
[
new EmailOtp("ada@example.com", "482913"),
new SmsOtp("+905550000001", "482913"),
new PushOtp("device-7f3a", "482913")
];
foreach (Notification otp in otps)
otp.Send();
Console.WriteLine($"Notification classes: {typeof(Notification).Assembly.GetTypes().Count(t => t.IsSubclassOf(typeof(Notification)))}");
public abstract class Notification(string recipient)
{
protected string Recipient => recipient;
public abstract void Send();
}
// Message type x channel: every combination is a separate class
public sealed class EmailOrderAlert(string to, string order) : Notification(to)
{
public override void Send() => Console.WriteLine($"[email] {Recipient} | Order {order} | Your order {order} has shipped.");
}
public sealed class SmsOrderAlert(string to, string order) : Notification(to)
{
public override void Send() => Console.WriteLine($"[sms] {Recipient} | Order {order} shipped.");
}
public sealed class PushOrderAlert(string to, string order) : Notification(to)
{
public override void Send() => Console.WriteLine($"[push] {Recipient} | Order {order} | Your order has shipped.");
}
public sealed class EmailStockReport(string to, int lowStock) : Notification(to)
{
public override void Send() => Console.WriteLine($"[email] {Recipient} | Daily stock | {lowStock} titles are below the reorder level.");
}
public sealed class SmsStockReport(string to, int lowStock) : Notification(to)
{
public override void Send() => Console.WriteLine($"[sms] {Recipient} | {lowStock} titles low.");
}
public sealed class PushStockReport(string to, int lowStock) : Notification(to)
{
public override void Send() => Console.WriteLine($"[push] {Recipient} | Daily stock | {lowStock} titles low.");
}
public sealed class EmailOtp(string to, string code) : Notification(to)
{
public override void Send() => Console.WriteLine($"[email] {Recipient} | Sign-in code | Your code is {code}. It expires in 3 minutes.");
}
public sealed class SmsOtp(string to, string code) : Notification(to)
{
public override void Send() => Console.WriteLine($"[sms] {Recipient} | Code {code}, valid for 3 min.");
}
public sealed class PushOtp(string to, string code) : Notification(to)
{
public override void Send() => Console.WriteLine($"[push] {Recipient} | Sign-in code | Your code is {code}. It expires in 5 minutes.");
}
If the same user requests the same code through all three channels, how many minutes of validity will each channel show? If the product team asks for a fourth channel and a fourth message type tomorrow, how many classes will be written?
Class Explosion: Two Dimensions, One Hierarchy
The output:
[email] ada@example.com | Sign-in code | Your code is 482913. It expires in 3 minutes.
[sms] +905550000001 | Code 482913, valid for 3 min.
[push] device-7f3a | Sign-in code | Your code is 482913. It expires in 5 minutes.
Notification classes: 9
Email and SMS say 3 minutes, push still says 5. The developer who made the change found two of the OTP classes and missed the third. That's not carelessness but the natural consequence of the structure: the knowledge of "what an OTP message is" is spread across three classes, and the knowledge of "how SMS is sent" across three more. Every piece of knowledge is copied as many times as there are options in the other dimension.
The root of the problem is that two independent things, the message type and the channel, are squeezed into a single inheritance hierarchy. With m message types and n channels, the class count is m × n. A fourth channel brings 3 new classes, a fourth message type 4 more; if both arrive, 16 classes. Since C# doesn't allow multiple inheritance, there's also no way to get the behaviors "being an OTP" and "going by SMS" from two separate base classes; you can only copy the shared code.
The Bridge Pattern: Abstraction and Implementor
GoF (Gang of Four: the 1994 Design Patterns book by Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides) defines Bridge's intent as decoupling an abstraction from its implementation so the two can vary independently. The book's example is a window system: different kinds of the Window abstraction (icon windows, transient windows) and window implementations on different operating systems. In our story the abstraction is the message type and the implementation is the channel.
Here the words "abstraction" and "implementation" shouldn't be confused with C#'s abstract class and interface. In Bridge, the abstraction is the high-level concept the client talks to ("send an OTP"), and the implementor is the low-level platform that concept is built on ("deliver this text to this recipient"). There are four roles. The abstraction is the base class holding a reference to the implementor. Refined abstractions are the actual message types deriving from it. The implementor is the channels' common interface. Concrete implementors are the individual channels. What connects the two hierarchies is that single reference inside the abstraction: that's the bridge.

Bridge in Bookstore
Let's define the channel, the implementor, first. GoF makes an important point: the implementor's interface doesn't have to resemble the abstraction's. Typically the implementor offers only primitive operations, and the abstraction builds higher-level operations on top of them. Our channel's primitive is "deliver", plus two properties announcing the channel's capabilities:
INotificationChannel email = new EmailChannel();
INotificationChannel sms = new SmsChannel();
INotificationChannel push = new PushChannel();
(INotificationChannel Channel, string Recipient)[] targets =
[(email, "ada@example.com"), (sms, "+905550000001"), (push, "device-7f3a")];
foreach ((INotificationChannel channel, string recipient) in targets)
new OneTimePassword(channel, "482913").SendTo(recipient);
new StockReport(sms, ["978-0141439518", "978-0547928227", "978-0061120084", "978-0451524935", "978-0743273565",
"978-0060935467", "978-0316769488", "978-0439023481", "978-0062315007", "978-0385490818"]).SendTo("+905550000009");
new OrderAlert(push, "BK-1001").SendTo("device-7f3a");
// Implementor: the primitive operations a channel offers
public interface INotificationChannel
{
int? MaxBodyLength { get; }
bool SupportsSubject { get; }
void Deliver(string recipient, string? subject, string body);
}
public sealed class EmailChannel : INotificationChannel
{
public int? MaxBodyLength => null;
public bool SupportsSubject => true;
public void Deliver(string recipient, string? subject, string body) =>
Console.WriteLine($"[email] {recipient} | {subject} | {body}");
}
public sealed class SmsChannel : INotificationChannel
{
public int? MaxBodyLength => 160;
public bool SupportsSubject => false;
public void Deliver(string recipient, string? subject, string body) =>
Console.WriteLine($"[sms] {recipient} | {body} ({body.Length} chars)");
}
public sealed class PushChannel : INotificationChannel
{
public int? MaxBodyLength => 120;
public bool SupportsSubject => true;
public void Deliver(string recipient, string? subject, string body) =>
Console.WriteLine($"[push] {recipient} | {subject} | {body}");
}
// Abstraction: what the message is; how it's delivered is left to the channel
public abstract class Notification(INotificationChannel channel)
{
protected abstract string Subject { get; }
protected abstract string Body { get; }
public void SendTo(string recipient)
{
string body = channel.MaxBodyLength is int max && Body.Length > max ? Body[..(max - 3)] + "..." : Body;
channel.Deliver(recipient, channel.SupportsSubject ? Subject : null, body);
}
}
// Refined abstractions: each message type is written once
public sealed class OneTimePassword(INotificationChannel channel, string code) : Notification(channel)
{
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(3);
protected override string Subject => "Sign-in code";
protected override string Body => $"Your code is {code}. It expires in {Lifetime.TotalMinutes:0} minutes.";
}
public sealed class OrderAlert(INotificationChannel channel, string orderNumber) : Notification(channel)
{
protected override string Subject => $"Order {orderNumber}";
protected override string Body => $"Your order {orderNumber} has shipped.";
}
public sealed class StockReport(INotificationChannel channel, IReadOnlyList<string> lowStockIsbns) : Notification(channel)
{
protected override string Subject => "Daily stock";
protected override string Body => $"{lowStockIsbns.Count} titles below reorder level: {string.Join(", ", lowStockIsbns)}";
}
[email] ada@example.com | Sign-in code | Your code is 482913. It expires in 3 minutes.
[sms] +905550000001 | Your code is 482913. It expires in 3 minutes. (45 chars)
[push] device-7f3a | Sign-in code | Your code is 482913. It expires in 3 minutes.
[sms] +905550000009 | 10 titles below reorder level: 978-0141439518, 978-0547928227, 978-0061120084, 978-0451524935, 978-0743273565, 978-0060935467, 978-0316769488, 978-0439023481... (160 chars)
[push] device-7f3a | Order BK-1001 | Your order BK-1001 has shipped.
The channels don't know what the message is; they only know Deliver and their own limits. The message types don't know how a channel works either; they only provide their subject and body. The two meet in the abstraction's SendTo: it shortens the body to the channel's length limit and skips the subject if the channel doesn't support one. The stock report was cut at 160 characters over SMS; it wouldn't have been over email. The OTP's lifetime now lives in one place, OneTimePassword.Lifetime; all three channels say the same 3 minutes.
Growth is now addition. A fourth channel, say a webhook, is a single class implementing INotificationChannel; the existing three message types go to it without any change. A fourth message type, say a price-drop alert, is a single class deriving from Notification; it goes out through all three channels. Instead of 3 × 3 = 9 classes, 3 + 3 = 6; instead of 4 × 4 = 16, 4 + 4 = 8.
There's also a runtime gain. In the inheritance structure, which channel an OTP went through was fixed at compile time by which class got new-ed. With Bridge the channel comes through the constructor: you can give the same OneTimePassword a different channel based on the user's preference, the SMS provider's current health or a feature flag. GoF lists this among the pattern's benefits: the implementation isn't bound permanently to the abstraction.
Bridges in .NET: HttpClient and ILogger
The cleanest example of Bridge in .NET is HttpClient. HttpClient derives from HttpMessageInvoker and takes an HttpMessageHandler in its constructor. On the abstraction side there's a rich API (Application Programming Interface): GetStringAsync, GetFromJsonAsync, DeleteAsync, a base address, default headers, a timeout. On the implementor side there's a single primitive operation: SendAsync. In a real application the handler is SocketsHttpHandler; in a test you can pass your own:
using System.Net;
using System.Net.Http.Json;
// The abstraction (HttpClient) and the implementor (HttpMessageHandler) meet in the constructor
using HttpClient client = new(new InMemoryCatalogHandler()) { BaseAddress = new Uri("https://catalog.bookstore.test/") };
string raw = await client.GetStringAsync("books/978-0141439518");
Book? book = await client.GetFromJsonAsync<Book>("books/978-0141439518");
HttpResponseMessage missing = await client.DeleteAsync("books/000");
Console.WriteLine(raw);
Console.WriteLine(book);
Console.WriteLine((int)missing.StatusCode);
public sealed record Book(string Isbn, string Title);
// Implementor: a single primitive operation, SendAsync
public sealed class InMemoryCatalogHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Console.WriteLine($"[handler] {request.Method} {request.RequestUri!.AbsolutePath}");
HttpResponseMessage response = request.RequestUri.AbsolutePath == "/books/978-0141439518"
? new(HttpStatusCode.OK) { Content = JsonContent.Create(new Book("978-0141439518", "Emma")) }
: new(HttpStatusCode.NotFound);
return Task.FromResult(response);
}
}
[handler] GET /books/978-0141439518
[handler] GET /books/978-0141439518
[handler] DELETE /books/000
{"isbn":"978-0141439518","title":"Emma"}
Book { Isbn = 978-0141439518, Title = Emma }
404
Three different abstraction methods came down to one primitive. Deserializing JSON (JavaScript Object Notation), interpreting the status code and combining the URI (Uniform Resource Identifier) with the base address are the abstraction's job; actually sending bytes somewhere is the implementor's. The same HttpClient API works without knowing whether the handler underneath goes to a socket, an in-memory dictionary or a browser's fetch API. Chaining handlers together (DelegatingHandler) will come up in this series' Decorator and Chain of Responsibility posts.
The second example is logging. Application code talks to ILogger<T>; ILoggerProvider implementations decide whether the message goes to the console, a file or a log aggregation service:
using Microsoft.Extensions.Logging;
using ILoggerFactory factory = LoggerFactory.Create(builder => builder
.SetMinimumLevel(LogLevel.Information)
.AddProvider(new LineLoggerProvider("audit"))
.AddProvider(new LineLoggerProvider("ops")));
// The client only sees the abstraction: ILogger<T>
ILogger<OrderService> logger = factory.CreateLogger<OrderService>();
logger.LogInformation("Order {OrderNumber} shipped", "BK-1001");
logger.LogDebug("Cache hit for {OrderNumber}", "BK-1001");
public sealed class OrderService;
// Implementor side: each provider creates its own ILogger
public sealed class LineLoggerProvider(string sink) : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => new LineLogger(sink, categoryName);
public void Dispose() { }
}
public sealed class LineLogger(string sink, string category) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Console.WriteLine($"[{sink}] {logLevel} {category}: {formatter(state, exception)}");
}
[audit] Information OrderService: Order BK-1001 shipped
[ops] Information OrderService: Order BK-1001 shipped
A single LogInformation call went to two providers. LogDebug reached neither, even though both loggers' IsEnabled always returns true: the abstraction side applied the minimum level filter. In the .NET source, the logger returned by LoggerFactory is the internal Logger class; it holds an array of loggers, one from each provider, applies the filter and passes the message to each in turn, continuing to call the others even if one provider throws. The abstraction's high-level behavior (filtering, categories, message templates, fanning out to several targets) and the implementor's primitive (write this line there) grow separately. The ASP.NET Core Web API series post "Logging in ASP.NET Core: ILogger, Log Levels, Structured Logging and Serilog" covers how to use this infrastructure.
Bridge or Adapter?
The code structure of the two patterns is almost the same: a class holds a reference to an interface and delegates work to it. The difference is in intent and timing. GoF stresses this when comparing the structural patterns: adapter makes designed things work together after the fact; bridge sets them up to work together from the start.
| Question | Adapter | Bridge |
|---|---|---|
| When does it come in? | After design, when incompatible pieces show up | During design, when two dimensions are foreseen |
| Which problem does it solve? | Fitting an existing interface to the expected one | Separating two independent axes of change |
| Who designs the interfaces? | The adaptee is someone else's; you only write the adapter | You design the abstraction and implementor together |
| How many hierarchies? | Usually one adapter, one adaptee | Two hierarchies, both grow |
| Example | StreamReader, the payment provider adapters | HttpClient/HttpMessageHandler, ILogger/ILoggerProvider |
The payment adapters in the previous post were adapters because the SDKs (Software Development Kits) already existed and we couldn't change them. The notification channels are our own design from the start; we shaped the channel interface around what the message types need. In practice the two also appear together: if a concrete implementor wraps a third-party SMS SDK, that class is both Bridge's implementor and an adapter at the same time.
When Not to Use It
When change happens along one dimension. If Bookstore has one message type and three channels, or one channel and three message types, there's no second hierarchy to bridge. An interface and a few implementations are enough; adding a base class on the abstraction side only adds indirection.
When one of the dimensions is speculative. Building two hierarchies today in a single-channel system "because another channel might come one day" is the trap we discussed in the SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) and Design Principles series post "Open/Closed Principle: Extending Without Modifying and the Speculative Generality Trap". When the second channel arrives, moving to Bridge through refactoring is cheap while the code is small.
When the dimensions aren't really independent. If every message type behaves completely differently on every channel, for example a push notification has a deep link, an email has an HTML (HyperText Markup Language) template and attachments, an SMS has a shortened text, and these vary by message type, the implementor's primitives swell with every new need and the abstraction fills up with if (channel is SmsChannel) checks. In that case the bridge isn't separating two dimensions, it's only hiding the point where they merge; a template or renderer per combination may be a more honest design.
Common Pitfalls
Making the implementor a mirror of the abstraction. If you put SendOtp, SendOrderAlert and SendStockReport methods on INotificationChannel, every new message type adds a new method to every channel, and the class explosion comes back as a method explosion. The implementor's interface must stay primitive.
Branching on the channel type. The moment you see a channel is SmsChannel check in the abstraction, the bridge has cracked. If you need channel-specific information, add it to the implementor's contract as a capability; MaxBodyLength and SupportsSubject above are examples.
A leaky abstraction. If the channel's limits never surface in the abstraction, problems show up at runtime: a message written without knowing the 160-character SMS limit is either cut off at the provider or billed as several SMS messages. That's why modeling capabilities explicitly matters.
Taking Bridge to be just dependency injection. Taking every interface through the constructor builds the implementor side; what distinguishes Bridge is that the abstraction side grows with its own hierarchy too. A single OrderService taking an IEmailSender isn't Bridge, just an ordinary dependency.
A Small Experiment
★☆☆ Add a WebhookChannel to the Bridge example: no subject support, no length limit, and it writes the message as JSON. How many files did you have to touch? How many classes would you have added for the same change in the opening inheritance structure?
★★☆ Add a fourth message type called PriceDropAlert (ISBN (International Standard Book Number), old and new price). Then think of a rule that splits the body into two parts instead of cutting it when it exceeds 160 characters on SMS: does this rule belong to the abstraction or the implementor?
★★★ Write a FallbackChannel that works from the user's channel preference and falls back to email if the preferred channel fails; it should implement INotificationChannel too. Where does this class sit in Bridge, and which pattern in this series does it resemble?
Bridge connected two independent hierarchies with a single reference. The next post is also about structure, but this time the problem is tree-shaped: using the Composite pattern to treat nested structures like book bundles through the same interface as a single book.