BrandGhost
Bridge Pattern Real-World Example in C#: Complete Implementation

Bridge Pattern Real-World Example in C#: Complete Implementation

Bridge Pattern Real-World Example in C#: Complete Implementation

Most bridge pattern tutorials draw a shape hierarchy where Circle and Square each get a Red and Blue variant. That teaches the mechanics, but it won't help you the next time you're building a notification system that needs to send urgent, scheduled, and marketing messages across email, SMS, push, and Slack -- without an explosion of classes. This article builds a complete bridge pattern real-world example in C# -- a notification delivery system where the abstraction (notification type) and the implementation (delivery channel) evolve independently.

By the end, you'll have compilable classes covering the full evolution: the problem that motivates the bridge pattern, the implementor interface, four concrete channel implementations, three refined abstractions with distinct delivery behaviors, error handling, logging, and dependency injection registration using keyed services. If you want to see how the bridge pattern fits alongside other structural patterns like the adapter, this article gives you the practical foundation.

The Problem: A Notification Class Explosion

You're building a notification platform. The business needs three notification types -- urgent, scheduled, and marketing -- and four delivery channels -- email, SMS, push notifications, and Slack. Without the bridge pattern, you end up with a class for every combination:

public class UrgentEmailNotification { /* ... */ }
public class UrgentSmsNotification { /* ... */ }
public class UrgentPushNotification { /* ... */ }
public class UrgentSlackNotification { /* ... */ }
public class ScheduledEmailNotification { /* ... */ }
public class ScheduledSmsNotification { /* ... */ }
// ... 12 classes total, and growing

Adding a fifth channel means three new classes. Adding a fourth notification type means four new classes. The combination count grows multiplicatively, and every class duplicates either channel-specific logic or notification-type logic. Testing becomes a nightmare because you can't verify channel behavior independently from notification behavior.

This is exactly the problem the bridge pattern solves. It separates the abstraction (what kind of notification) from the implementation (how it gets delivered), letting each dimension vary independently. Instead of 12 classes, you get 3 notification types + 4 channels = 7 classes, and adding a new channel or notification type never touches existing code.

Defining the Implementor Interface

The bridge pattern starts with the implementor -- the interface that defines delivery channel operations. In our notification system, every channel needs to send a message and validate that a recipient address is valid for that channel:

public interface IDeliveryChannel
{
    string ChannelName { get; }

    Task<DeliveryResult> SendAsync(
        string recipient,
        string subject,
        string body,
        CancellationToken cancellationToken = default);

    bool ValidateRecipient(string recipient);
}

public record DeliveryResult
{
    public bool Success { get; init; }
    public string? MessageId { get; init; }
    public string? ErrorMessage { get; init; }
    public DateTimeOffset Timestamp { get; init; }
        = DateTimeOffset.UtcNow;
}

The IDeliveryChannel interface is deliberately focused. It doesn't know about urgency, scheduling, or marketing templates -- those concerns belong to the abstraction side of the bridge pattern. Each channel implementation only needs to know how to deliver a message through its specific medium and whether a given recipient address is valid. This separation is what makes the bridge pattern powerful compared to a flat inheritance hierarchy.

Notice the DeliveryResult record. Every channel returns the same result type, giving the abstraction layer a consistent way to handle outcomes regardless of which channel delivered the message. The CancellationToken parameter ensures our async operations can be cleanly cancelled -- a production necessity that tutorial examples often skip.

Concrete Implementors: Four Delivery Channels

Each concrete implementor translates the common SendAsync contract into channel-specific behavior. Let's build all four.

EmailChannel

The email channel handles SMTP-based delivery with basic address validation. In a production system, you'd inject an ISmtpClient or a service like SendGrid, but the bridge pattern structure remains identical:

public sealed class EmailChannel : IDeliveryChannel
{
    private readonly ILogger<EmailChannel> _logger;

    public EmailChannel(ILogger<EmailChannel> logger)
    {
        _logger = logger;
    }

    public string ChannelName => "Email";

    public async Task<DeliveryResult> SendAsync(
        string recipient,
        string subject,
        string body,
        CancellationToken cancellationToken = default)
    {
        if (!ValidateRecipient(recipient))
        {
            return new DeliveryResult
            {
                Success = false,
                ErrorMessage =
                    $"Invalid email address: {recipient}"
            };
        }

        _logger.LogInformation(
            "Sending email to {Recipient} with subject " +
            "'{Subject}'",
            recipient,
            subject);

        // In production, this would call an SMTP client
        // or a service like SendGrid
        await Task.Delay(100, cancellationToken);

        var messageId = $"email_{Guid.NewGuid():N}";
        _logger.LogInformation(
            "Email delivered: {MessageId}", messageId);

        return new DeliveryResult
        {
            Success = true,
            MessageId = messageId
        };
    }

    public bool ValidateRecipient(string recipient) =>
        !string.IsNullOrWhiteSpace(recipient)
        && recipient.Contains('@')
        && recipient.Contains('.');
}

SmsChannel

SMS delivery introduces channel-specific constraints like character limits. The SmsChannel truncates messages that exceed 160 characters and validates phone numbers using the E.164 format:

public sealed class SmsChannel : IDeliveryChannel
{
    private readonly ILogger<SmsChannel> _logger;

    public SmsChannel(ILogger<SmsChannel> logger)
    {
        _logger = logger;
    }

    public string ChannelName => "SMS";

    public async Task<DeliveryResult> SendAsync(
        string recipient,
        string subject,
        string body,
        CancellationToken cancellationToken = default)
    {
        if (!ValidateRecipient(recipient))
        {
            return new DeliveryResult
            {
                Success = false,
                ErrorMessage =
                    $"Invalid phone number: {recipient}"
            };
        }

        // SMS messages have character limits
        var smsBody = body.Length > 160
            ? body[..157] + "..."
            : body;

        _logger.LogInformation(
            "Sending SMS to {Recipient}: {Body}",
            recipient,
            smsBody);

        // In production, use Twilio, AWS SNS, etc.
        await Task.Delay(150, cancellationToken);

        return new DeliveryResult
        {
            Success = true,
            MessageId = $"sms_{Guid.NewGuid():N}"
        };
    }

    public bool ValidateRecipient(string recipient) =>
        !string.IsNullOrWhiteSpace(recipient)
        && recipient.StartsWith('+')
        && recipient.Length >= 10;
}

PushNotificationChannel

Push notifications target device tokens rather than human-readable addresses. This channel masks tokens in log output for security and validates that tokens meet a minimum length requirement:

public sealed class PushNotificationChannel
    : IDeliveryChannel
{
    private readonly ILogger<PushNotificationChannel>
        _logger;

    public PushNotificationChannel(
        ILogger<PushNotificationChannel> logger)
    {
        _logger = logger;
    }

    public string ChannelName => "Push";

    public async Task<DeliveryResult> SendAsync(
        string recipient,
        string subject,
        string body,
        CancellationToken cancellationToken = default)
    {
        if (!ValidateRecipient(recipient))
        {
            return new DeliveryResult
            {
                Success = false,
                ErrorMessage =
                    $"Invalid device token: {recipient}"
            };
        }

        _logger.LogInformation(
            "Sending push notification to device " +
            "{DeviceToken}: {Subject}",
            recipient[..8] + "...",
            subject);

        // In production, use Firebase Cloud Messaging
        // or Apple Push Notification Service
        await Task.Delay(80, cancellationToken);

        return new DeliveryResult
        {
            Success = true,
            MessageId = $"push_{Guid.NewGuid():N}"
        };
    }

    public bool ValidateRecipient(string recipient) =>
        !string.IsNullOrWhiteSpace(recipient)
        && recipient.Length >= 32;
}

SlackChannel

public sealed class SlackChannel : IDeliveryChannel
{
    private readonly ILogger<SlackChannel> _logger;

    public SlackChannel(ILogger<SlackChannel> logger)
    {
        _logger = logger;
    }

    public string ChannelName => "Slack";

    public async Task<DeliveryResult> SendAsync(
        string recipient,
        string subject,
        string body,
        CancellationToken cancellationToken = default)
    {
        if (!ValidateRecipient(recipient))
        {
            return new DeliveryResult
            {
                Success = false,
                ErrorMessage =
                    $"Invalid Slack channel: {recipient}"
            };
        }

        var payload = $"*{subject}*
{body}";

        _logger.LogInformation(
            "Posting to Slack channel {Channel}",
            recipient);

        // In production, use Slack's Web API
        await Task.Delay(120, cancellationToken);

        return new DeliveryResult
        {
            Success = true,
            MessageId = $"slack_{Guid.NewGuid():N}"
        };
    }

    public bool ValidateRecipient(string recipient) =>
        !string.IsNullOrWhiteSpace(recipient)
        && recipient.StartsWith('#');
}

Each channel implementation follows the same contract but handles validation, formatting, and delivery differently. The EmailChannel checks for @ and . characters, the SmsChannel truncates long messages, the PushNotificationChannel masks device tokens in logs for security, and the SlackChannel formats messages with Slack's markdown syntax. None of them know anything about notification urgency or scheduling -- that's the abstraction's job.

The Abstraction: Notification Base Class

Now we build the other side of the bridge pattern. The abstraction holds a reference to an IDeliveryChannel and defines the notification-type behavior:

public abstract class Notification
{
    protected readonly IDeliveryChannel Channel;
    protected readonly ILogger Logger;

    protected Notification(
        IDeliveryChannel channel,
        ILogger logger)
    {
        Channel = channel
            ?? throw new ArgumentNullException(
                nameof(channel));
        Logger = logger
            ?? throw new ArgumentNullException(
                nameof(logger));
    }

    public string Recipient { get; set; } = "";
    public string Subject { get; set; } = "";
    public string Body { get; set; } = "";

    public async Task<DeliveryResult> SendAsync(
        CancellationToken cancellationToken = default)
    {
        if (!Channel.ValidateRecipient(Recipient))
        {
            Logger.LogWarning(
                "Recipient validation failed for " +
                "{Recipient} on {Channel}",
                Recipient,
                Channel.ChannelName);

            return new DeliveryResult
            {
                Success = false,
                ErrorMessage =
                    "Recipient validation failed"
            };
        }

        var (subject, body) = Prepare();

        return await Channel.SendAsync(
            Recipient,
            subject,
            body,
            cancellationToken);
    }

    protected abstract (string Subject, string Body)
        Prepare();
}

The bridge pattern's key insight is in the constructor: Notification receives an IDeliveryChannel rather than inheriting from a channel-specific base. The Prepare method is the customization point -- each notification type transforms the subject and body according to its own rules before delegating delivery to the channel. This design lets you pair any notification type with any channel at runtime without creating combination classes.

If you're familiar with how the strategy design pattern in C# swaps algorithms, you'll notice a surface-level similarity. The difference is that the bridge pattern separates two independent hierarchies that both need to evolve, while strategy swaps one behavior within a single hierarchy. Understanding that distinction is critical for choosing the right pattern.

Refined Abstractions: Three Notification Types

Each refined abstraction extends Notification with behavior specific to its notification category.

UrgentNotification

Urgent notifications retry on failure and can escalate to a secondary channel:

public sealed class UrgentNotification : Notification
{
    private readonly IDeliveryChannel? _escalationChannel;
    private readonly int _maxRetries;

    public UrgentNotification(
        IDeliveryChannel channel,
        ILogger<UrgentNotification> logger,
        IDeliveryChannel? escalationChannel = null,
        int maxRetries = 3)
        : base(channel, logger)
    {
        _escalationChannel = escalationChannel;
        _maxRetries = maxRetries;
    }

    protected override (string Subject, string Body)
        Prepare()
    {
        var subject = $"[URGENT] {Subject}";
        var body =
            $"⚠️ URGENT NOTIFICATION ⚠️

{Body}" +
            $"

This message requires immediate " +
            $"attention.";
        return (subject, body);
    }

    public new async Task<DeliveryResult> SendAsync(
        CancellationToken cancellationToken = default)
    {
        DeliveryResult? result = null;

        for (int attempt = 1;
            attempt <= _maxRetries;
            attempt++)
        {
            result = await base.SendAsync(
                cancellationToken);

            if (result.Success)
            {
                Logger.LogInformation(
                    "Urgent notification delivered " +
                    "on attempt {Attempt} via " +
                    "{Channel}",
                    attempt,
                    Channel.ChannelName);
                return result;
            }

            Logger.LogWarning(
                "Attempt {Attempt}/{MaxRetries} " +
                "failed for urgent notification: " +
                "{Error}",
                attempt,
                _maxRetries,
                result.ErrorMessage);

            if (attempt < _maxRetries)
            {
                await Task.Delay(
                    TimeSpan.FromSeconds(
                        Math.Pow(2, attempt)),
                    cancellationToken);
            }
        }

        // Escalate to secondary channel if available
        if (_escalationChannel is not null)
        {
            Logger.LogWarning(
                "Escalating urgent notification " +
                "to {Channel}",
                _escalationChannel.ChannelName);

            var (subject, body) = Prepare();
            return await _escalationChannel.SendAsync(
                Recipient,
                subject,
                body + "

[ESCALATED: Primary " +
                    "channel failed]",
                cancellationToken);
        }

        return result!;
    }
}

The UrgentNotification adds retry logic with exponential backoff and an optional escalation channel. If all retries fail on the primary channel, it can automatically fall back to a secondary channel -- for example, escalating from push notifications to SMS. The bridge pattern makes this possible because the notification doesn't care which channel type it's using. If you're interested in how the observer design pattern in C# handles event-driven notifications, that's a complementary pattern worth exploring.

ScheduledNotification

Scheduled notifications defer delivery until a specified time and support batching. This is where the bridge pattern's separation pays off -- the scheduling logic lives entirely in the abstraction, independent of whichever channel eventually delivers the message:

public sealed class ScheduledNotification : Notification
{
    public ScheduledNotification(
        IDeliveryChannel channel,
        ILogger<ScheduledNotification> logger)
        : base(channel, logger)
    {
    }

    public DateTimeOffset ScheduledTime { get; set; }
        = DateTimeOffset.UtcNow;
    public bool IsBatchable { get; set; }
    public string BatchGroup { get; set; } = "default";

    protected override (string Subject, string Body)
        Prepare()
    {
        var subject = Subject;
        var body = Body;

        if (IsBatchable)
        {
            body = $"[Batch: {BatchGroup}]

{body}";
        }

        return (subject, body);
    }

    public new async Task<DeliveryResult> SendAsync(
        CancellationToken cancellationToken = default)
    {
        var delay = ScheduledTime - DateTimeOffset.UtcNow;

        if (delay > TimeSpan.Zero)
        {
            Logger.LogInformation(
                "Notification scheduled for " +
                "{ScheduledTime}. Waiting {Delay}...",
                ScheduledTime,
                delay);

            await Task.Delay(delay, cancellationToken);
        }

        Logger.LogInformation(
            "Delivering scheduled notification " +
            "via {Channel}",
            Channel.ChannelName);

        return await base.SendAsync(cancellationToken);
    }
}

MarketingNotification

Marketing notifications apply templates and include tracking metadata:

public sealed class MarketingNotification : Notification
{
    public MarketingNotification(
        IDeliveryChannel channel,
        ILogger<MarketingNotification> logger)
        : base(channel, logger)
    {
    }

    public string? TemplateId { get; set; }
    public string CampaignId { get; set; } = "";
    public Dictionary<string, string> TrackingParams
    { get; set; } = new();

    protected override (string Subject, string Body)
        Prepare()
    {
        var body = Body;

        if (!string.IsNullOrEmpty(TemplateId))
        {
            body = ApplyTemplate(TemplateId, body);
        }

        // Append tracking parameters
        if (TrackingParams.Count > 0)
        {
            var trackingFooter = string.Join(
                " | ",
                TrackingParams.Select(
                    kvp => $"{kvp.Key}={kvp.Value}"));
            body +=
                $"

---
Tracking: {trackingFooter}";
        }

        if (!string.IsNullOrEmpty(CampaignId))
        {
            body +=
                $"
Campaign: {CampaignId}";
        }

        Logger.LogInformation(
            "Prepared marketing notification for " +
            "campaign {CampaignId}",
            CampaignId);

        return (Subject, body);
    }

    private static string ApplyTemplate(
        string templateId,
        string body)
    {
        // In production, load from a template engine
        // like Razor, Scriban, or Liquid
        return $"<!-- Template: {templateId} -->
" +
            $"{body}
" +
            $"<!-- End Template -->";
    }
}

Each refined abstraction customizes the Prepare method and optionally overrides SendAsync to add behavior like retries, delays, or tracking. None of them reference a specific channel type -- they work with any IDeliveryChannel implementation. That's the bridge pattern in action: two independent hierarchies connected by a composition reference rather than inheritance.

Error Handling and Logging Patterns

Production notification systems need structured error handling. Here's a utility class that wraps the bridge pattern's delivery mechanism with consistent error handling:

public sealed class NotificationDispatcher
{
    private readonly ILogger<NotificationDispatcher>
        _logger;

    public NotificationDispatcher(
        ILogger<NotificationDispatcher> logger)
    {
        _logger = logger;
    }

    public async Task<DeliveryResult> DispatchAsync(
        Notification notification,
        CancellationToken cancellationToken = default)
    {
        try
        {
            _logger.LogInformation(
                "Dispatching {NotificationType} via " +
                "{Channel} to {Recipient}",
                notification.GetType().Name,
                notification is Notification n
                    ? "channel"
                    : "unknown",
                notification.Recipient);

            var result = await notification.SendAsync(
                cancellationToken);

            if (!result.Success)
            {
                _logger.LogError(
                    "Delivery failed: {Error}",
                    result.ErrorMessage);
            }

            return result;
        }
        catch (OperationCanceledException)
        {
            _logger.LogWarning(
                "Notification delivery was cancelled");
            throw;
        }
        catch (Exception ex)
        {
            _logger.LogError(
                ex,
                "Unexpected error during notification " +
                "delivery");

            return new DeliveryResult
            {
                Success = false,
                ErrorMessage = ex.Message
            };
        }
    }
}

This dispatcher doesn't break the bridge pattern -- it sits above it, providing a consistent layer for telemetry and exception handling. The dispatcher works with the Notification base type and doesn't need to know which concrete notification type or channel is being used. If you're familiar with how the command design pattern in C# wraps operations with cross-cutting concerns, you'll recognize a similar approach here.

Dependency Injection with Keyed Services

Modern .NET 8+ supports keyed services in the DI container, which pairs perfectly with the bridge pattern. Each channel gets registered with a key, and notification factories can request specific channels by name:

public static class NotificationServiceExtensions
{
    public static IServiceCollection
        AddNotificationServices(
            this IServiceCollection services)
    {
        // Register delivery channels as keyed services
        services.AddKeyedSingleton<IDeliveryChannel,
            EmailChannel>("email");
        services.AddKeyedSingleton<IDeliveryChannel,
            SmsChannel>("sms");
        services.AddKeyedSingleton<IDeliveryChannel,
            PushNotificationChannel>("push");
        services.AddKeyedSingleton<IDeliveryChannel,
            SlackChannel>("slack");

        // Register the dispatcher
        services.AddTransient<NotificationDispatcher>();

        // Register a factory for creating notifications
        services.AddTransient<
            INotificationFactory,
            NotificationFactory>();

        return services;
    }
}

The factory uses IServiceProvider with keyed lookups to resolve the right channel:

public interface INotificationFactory
{
    UrgentNotification CreateUrgent(
        string channelKey,
        string? escalationChannelKey = null);
    ScheduledNotification CreateScheduled(
        string channelKey);
    MarketingNotification CreateMarketing(
        string channelKey);
}

public sealed class NotificationFactory
    : INotificationFactory
{
    private readonly IServiceProvider _provider;
    private readonly ILoggerFactory _loggerFactory;

    public NotificationFactory(
        IServiceProvider provider,
        ILoggerFactory loggerFactory)
    {
        _provider = provider;
        _loggerFactory = loggerFactory;
    }

    public UrgentNotification CreateUrgent(
        string channelKey,
        string? escalationChannelKey = null)
    {
        var channel = _provider
            .GetRequiredKeyedService<IDeliveryChannel>(
                channelKey);

        IDeliveryChannel? escalation = null;
        if (escalationChannelKey is not null)
        {
            escalation = _provider
                .GetRequiredKeyedService<
                    IDeliveryChannel>(
                    escalationChannelKey);
        }

        return new UrgentNotification(
            channel,
            _loggerFactory
                .CreateLogger<UrgentNotification>(),
            escalation);
    }

    public ScheduledNotification CreateScheduled(
        string channelKey)
    {
        var channel = _provider
            .GetRequiredKeyedService<IDeliveryChannel>(
                channelKey);

        return new ScheduledNotification(
            channel,
            _loggerFactory
                .CreateLogger<ScheduledNotification>());
    }

    public MarketingNotification CreateMarketing(
        string channelKey)
    {
        var channel = _provider
            .GetRequiredKeyedService<IDeliveryChannel>(
                channelKey);

        return new MarketingNotification(
            channel,
            _loggerFactory
                .CreateLogger<MarketingNotification>());
    }
}

The factory cleanly wires the bridge pattern's two hierarchies together through the DI container. Calling code asks for a notification type and a channel key; the factory resolves the appropriate IDeliveryChannel and composes it with the right Notification subclass. This approach leverages inversion of control to keep your application code decoupled from both concrete notification types and channel implementations.

Wiring It All Up in Program.cs

Here's the complete Program.cs that demonstrates the bridge pattern in action:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

var services = new ServiceCollection();

services.AddLogging(builder =>
    builder.AddConsole().SetMinimumLevel(
        LogLevel.Information));

services.AddNotificationServices();

var provider = services.BuildServiceProvider();

var factory = provider
    .GetRequiredService<INotificationFactory>();
var dispatcher = provider
    .GetRequiredService<NotificationDispatcher>();

// Urgent notification via email with SMS escalation
var urgent = factory.CreateUrgent(
    "email",
    escalationChannelKey: "sms");
urgent.Recipient = "[email protected]";
urgent.Subject = "Database CPU at 95%";
urgent.Body = "Production database server db-primary " +
    "is experiencing high CPU utilization.";

var urgentResult = await dispatcher.DispatchAsync(urgent);
Console.WriteLine(
    $"Urgent: {urgentResult.Success} " +
    $"({urgentResult.MessageId})");

// Scheduled notification via Slack
var scheduled = factory.CreateScheduled("slack");
scheduled.Recipient = "#deployments";
scheduled.Subject = "Deployment Reminder";
scheduled.Body = "Version 2.4.1 deploys to production " +
    "at 3:00 PM UTC.";
scheduled.ScheduledTime = DateTimeOffset.UtcNow
    .AddSeconds(5);
scheduled.IsBatchable = true;
scheduled.BatchGroup = "deployments";

var scheduledResult = await dispatcher
    .DispatchAsync(scheduled);
Console.WriteLine(
    $"Scheduled: {scheduledResult.Success}");

// Marketing notification via push
var marketing = factory.CreateMarketing("push");
marketing.Recipient =
    "abc123def456ghi789jkl012mno345pq";
marketing.Subject = "New Feature Available!";
marketing.Body = "Check out our latest update with " +
    "improved performance.";
marketing.CampaignId = "campaign_2024_q1";
marketing.TemplateId = "new-feature-announce";
marketing.TrackingParams = new()
{
    ["utm_source"] = "push",
    ["utm_campaign"] = "q1_feature_launch"
};

var marketingResult = await dispatcher
    .DispatchAsync(marketing);
Console.WriteLine(
    $"Marketing: {marketingResult.Success}");

Each notification type pairs with any channel through the factory. Changing "email" to "slack" in the urgent notification setup sends the exact same urgent message through Slack instead -- no code changes to UrgentNotification, no code changes to SlackChannel. That's the bridge pattern delivering on its promise: two dimensions of variation, zero class explosion.

Adding a New Channel Without Modifying Existing Code

The real test of the bridge pattern is extensibility. Suppose the business now wants Microsoft Teams notifications. Here's everything you need to add:

public sealed class TeamsChannel : IDeliveryChannel
{
    private readonly ILogger<TeamsChannel> _logger;

    public TeamsChannel(ILogger<TeamsChannel> logger)
    {
        _logger = logger;
    }

    public string ChannelName => "Teams";

    public async Task<DeliveryResult> SendAsync(
        string recipient,
        string subject,
        string body,
        CancellationToken cancellationToken = default)
    {
        if (!ValidateRecipient(recipient))
        {
            return new DeliveryResult
            {
                Success = false,
                ErrorMessage =
                    $"Invalid Teams webhook: {recipient}"
            };
        }

        _logger.LogInformation(
            "Posting to Teams channel via webhook");

        // In production, POST to the Teams webhook URL
        await Task.Delay(100, cancellationToken);

        return new DeliveryResult
        {
            Success = true,
            MessageId = $"teams_{Guid.NewGuid():N}"
        };
    }

    public bool ValidateRecipient(string recipient) =>
        Uri.TryCreate(
            recipient,
            UriKind.Absolute,
            out var uri)
        && uri.Host.Contains("webhook");
}

Then register it:

services.AddKeyedSingleton<IDeliveryChannel,
    TeamsChannel>("teams");

That's it. One new class, one new DI registration. Every existing notification type -- urgent, scheduled, marketing -- can now use Teams without modification. The UrgentNotification can retry through Teams, the ScheduledNotification can delay delivery to a Teams channel, and the MarketingNotification can append tracking data to a Teams message. No existing class was touched.

This is where the bridge pattern differs sharply from the decorator design pattern in C#. The decorator adds behavior to an existing object by wrapping it. The bridge pattern separates two independent hierarchies so both can grow without affecting each other. You might combine both -- decorating an IDeliveryChannel with logging or retry behavior -- but the patterns solve fundamentally different design problems.

Frequently Asked Questions

What is the difference between the bridge pattern and the strategy pattern in C#?

The bridge pattern and the strategy pattern both use composition over inheritance, but they address different design problems. The strategy pattern swaps one algorithm within a single class hierarchy. The bridge pattern separates two independent hierarchies -- both the abstraction and the implementation can have subclasses that evolve independently. In our notification example, both the notification types and the delivery channels have their own inheritance trees. A strategy pattern wouldn't give us that two-dimensional flexibility.

When should I use the bridge pattern instead of simple inheritance?

Use the bridge pattern when you have two independent dimensions of variation that would cause a class explosion through inheritance. If your notification system only has one notification type with multiple channels, a simple interface and DI would suffice. But once you need multiple notification types AND multiple channels -- each with their own specialized behavior -- the bridge pattern prevents the multiplicative class growth that makes codebases unmaintainable.

How does the bridge pattern work with dependency injection in .NET?

The bridge pattern pairs naturally with dependency injection because the core mechanism is composition: the abstraction receives its implementor through the constructor. In .NET 8+, keyed services make this even cleaner by letting you register multiple IDeliveryChannel implementations with distinct keys. The factory pattern resolves the right channel by key and injects it into the notification constructor, keeping all wiring centralized in your IServiceCollection configuration.

Can I combine the bridge pattern with other design patterns?

Absolutely. The bridge pattern frequently works alongside other patterns. You can use a factory to create the right abstraction-implementor pair (as we did here). You can decorate the implementor with logging, caching, or retry behavior. You can use the command pattern to queue notification operations for deferred execution. Patterns are composable tools, and production systems typically combine several of them to address different concerns at different architectural layers.

How do I test the bridge pattern effectively?

Test each side of the bridge independently. For delivery channels, write unit tests that verify SendAsync and ValidateRecipient against known inputs. For notification types, inject a mock or stub IDeliveryChannel and verify that Prepare transforms the subject and body correctly. Integration tests then verify that specific notification-channel combinations work end-to-end. The bridge pattern's separation makes each side independently testable, which is one of its biggest practical benefits.

Does the bridge pattern add unnecessary complexity for small projects?

If you have only one notification type or only one delivery channel, the bridge pattern is overkill. Use it when you genuinely have two orthogonal dimensions of variation that will grow independently. The notification system in this article justifies the bridge pattern because adding a new channel or notification type is a single-class operation with zero modifications to existing code. If that extensibility doesn't matter to your project, a simpler design is the better choice.

How does the bridge pattern compare to the adapter pattern?

The adapter pattern converts an existing incompatible interface into one your code expects. The bridge pattern is designed up-front to decouple an abstraction from its implementation so both can vary independently. Adapters retrofit compatibility; bridges architect it from the start. In practice, you might use an adapter inside a bridge -- for instance, wrapping a third-party SMS SDK behind IDeliveryChannel -- but the patterns serve different purposes.

Wrapping Up This Bridge Pattern Real-World Example

This implementation demonstrates the bridge pattern solving a real design problem -- a notification system where message types and delivery channels evolve independently without class explosion. We started with 12+ combination classes and ended with a clean architecture: four channel implementations behind IDeliveryChannel, three notification types extending Notification, and a factory that composes them at runtime through keyed DI services.

The bridge pattern shines whenever your domain has two orthogonal dimensions of variation. Notification types and delivery channels are one example. Report formats and data sources are another. UI controls and rendering engines are a third. In each case, the pattern gives you the same benefit: adding a new variant on either side is a single-class change with zero modifications to existing code.

Take this notification system, swap the simulated Task.Delay calls with real SMTP clients and push notification services, and you've got a production-ready delivery layer. The bridge pattern keeps your notification logic separate from your channel logic, your tests focused, and your architecture open to whatever channel the business asks for next.

When to Use Bridge Pattern in C#: Decision Guide with Examples

Discover when to use the bridge pattern in C# with decision criteria, real-world use cases, and guidance on choosing bridge over simpler alternatives.

Bridge Pattern Best Practices in C#: Code Organization and Maintainability

Master bridge pattern best practices in C# including interface segregation, testability, DI registration strategies, and avoiding common anti-patterns.

How to Implement Bridge Pattern in C#: Step-by-Step Guide

Learn how to implement the bridge pattern in C# with step-by-step code examples covering abstraction hierarchies, implementor interfaces, and DI registration.

An error has occurred. This application may no longer respond until reloaded. Reload