Bridge has a reputation for being the hardest pattern in the book to explain. I think that’s because most examples don’t show the problem first. Once you see the problem, which is a class explosion, Bridge stops being abstract and starts being obvious.
| Category | Structural |
| Smell fixed | S07: Message type and delivery channel multiply into separate classes |
| Code | patterns/structural/bridge |
The Scene
CornerStore tells customers when their order is confirmed and when it ships. Customers choose how they hear about it: Ava gets emails, Jo gets text messages.
[067] order.ship order=o-1002
sms to Jo: order o-1002 shipped, tracking SF-0002
[072] order.ship order=o-1003
email to Ava: order o-1003 shipped, tracking SF-0003
Same message, two delivery channels. Simple enough. So how many classes does it take?
The Smell
In the baseline: four. One for every combination.
OrderConfirmedEmail
OrderConfirmedSms
OrderShippedEmail
OrderShippedSms
Each one mixes two unrelated decisions, what to say and how to send it:
internal class OrderShippedSms(SmsSender sender)
{
// SMELL S07: shipping SMS owns the SMS-specific wording and sender call
public void Send(Customer customer, string orderId, string trackingNumber)
{
sender.Send(customer.Name, "order " + orderId + " shipped, tracking " + trackingNumber);
}
}
OrderShippedEmail is the same thing with a different sender. And OrderService has to take all four classes in its constructor and pick between them with nested if statements.
Now do the maths. The business wants a third message (“delivered”) and a third channel (push notifications). That’s not three classes plus three. It’s 3 × 3 = 9 classes, each a near-copy of another. Every new message multiplies by the number of channels, and every new channel multiplies by the number of messages.
The Idea
Bridge spots that there are two separate dimensions tangled into one class hierarchy, and splits them into two hierarchies connected by a reference, the “bridge”.
- One side is the abstraction: what you’re doing (the message).
- The other side is the implementor: how it gets done (the channel).
Think of a TV remote and the TV. Remotes vary (basic, universal, voice-controlled) and TVs vary (different brands and models). You don’t build a separate remote for every TV model. Any remote talks to any TV through a shared set of signals. Remotes and TVs improve independently.
The Refactor
The channel side gets its own small interface:
internal interface IChannel
{
void Send(string recipient, string message);
}
EmailSender and SmsSender implement it. They know nothing about orders.
The message side gets a base class that holds a channel. That reference is the bridge:
internal abstract class OrderMessage(IChannel channel)
{
protected void Deliver(Customer customer, string message)
{
channel.Send(customer.Name, message);
}
}
And each message only decides what to say:
internal sealed class OrderShippedMessage(IChannel channel) : OrderMessage(channel)
{
public void Send(Customer customer, string orderId, string trackingNumber)
{
Deliver(customer, "order " + orderId + " shipped, tracking " + trackingNumber);
}
}
OrderService now combines them at runtime, picking the channel from the customer’s preference:
new OrderShippedMessage(ChannelFor(order.Customer.Channel))
.Send(order.Customer, order.Id, order.TrackingNumber ?? "");
classDiagram
class OrderMessage {
<<abstract>>
+Deliver(customer, message)
}
class IChannel {
<<interface>>
+Send(recipient, message)
}
OrderMessage <|-- OrderConfirmedMessage
OrderMessage <|-- OrderShippedMessage
OrderMessage o--> IChannel : delegates delivery
IChannel <|.. EmailSender
IChannel <|.. SmsSender
OrderService --> OrderMessage : creates
OrderService --> IChannel : selects by preference
Back to the maths. Three messages and three channels is now 3 + 3 = 6 classes. Adding a push channel is one class. Adding a “delivered” message is one class. The two sides finally grow by adding, not multiplying.
What It Cost
10 files changed: about 103 lines added and 26 removed. The four combination classes are gone, replaced by two messages, a base class and an interface.
With only two messages and two channels, you could argue it’s roughly a wash today. Bridge is an investment in the next message and the next channel.
When Not to Use It
- Only one dimension really varies. If you’ll only ever send email, there’s no second hierarchy to separate. A message class per notification is fine.
- The dimensions aren’t independent. Bridge assumes any message works with any channel. If push notifications needed completely different content from SMS, the two sides aren’t truly separate, and forcing them apart creates awkward code.
Easily Confused With
- Adapter. Both put an interface between two sides. Adapter fixes a mismatch after the fact between things that already exist. Bridge is designed up front so two dimensions can vary independently. See Adapter vs Bridge.
- Strategy. Swapping the channel looks a lot like swapping a strategy. The difference is intent: Strategy swaps one algorithm inside an object, while Bridge splits a whole hierarchy in two so both halves can have their own subclasses.
Try It Yourself
dotnet build patterns/structural/bridge/Bridge.slnx
dotnet test patterns/structural/bridge/Bridge.slnx
dotnet run --project patterns/structural/bridge/src/CornerStore/CornerStore.csproj -- scenarios/trading-day.json
Exercise: add an OrderDeliveredMessage. Count the files you touch, then try the same thing in the baseline. That difference is the whole pattern.
The Bridge README has the participant table and a sequence diagram. The full change is in DIFF.md.