Manikandan β€” Manikandan
Updated on 6 min read Manikandan Design Patterns

Bridge Design Pattern in .NET Core API

A practical guide to the Bridge pattern in C#, with abstraction-implementation separation, two key variations, and a full UML diagram.

A practical guide to the Bridge pattern in C#, with abstraction-implementation separation, two key variations, and a full UML diagram.

Intro

Bridge is a structural design pattern that decouples an abstraction from its implementation so that both can be changed independently.

This article walks through the Bridge pattern in a practical .NET Core API context. The two variation sections use Factory Method exclusively as the implementation-side sample to demonstrate how Bridge composes with creational patterns.

What Is the Bridge Pattern?

The Bridge pattern solves a common inheritance problem. When a class can vary in two independent dimensions, a naive inheritance tree grows exponentially.

Bridge replaces that inheritance with composition:

  • The Abstraction defines high-level operations and holds a reference to an Implementor.
  • The Implementor defines the low-level contract.
  • Both hierarchies can grow independently without affecting each other.

This is especially useful in APIs where business logic (abstraction) and delivery mechanism (implementor) evolve at different rates.

Bridge Class Example

Consider a reporting service that can send reports through different channels. The channel (implementor) and the report logic (abstraction) are decoupled through Bridge.

public interface IReportChannel
{
void Deliver(string content);
}
public sealed class EmailChannel : IReportChannel
{
public void Deliver(string content) =>
Console.WriteLine($"[Email] Delivering: {content}");
}
public sealed class SlackChannel : IReportChannel
{
public void Deliver(string content) =>
Console.WriteLine($"[Slack] Delivering: {content}");
}
public class ReportService
{
protected readonly IReportChannel _channel;
public ReportService(IReportChannel channel)
{
_channel = channel;
}
public virtual void Send(string report)
{
_channel.Deliver(report);
}
}

The abstraction (ReportService) holds the implementor (IReportChannel) by composition. Neither knows about the other’s concrete type.

Why This Works

  • ReportService never imports EmailChannel or SlackChannel directly.
  • Adding a new channel (e.g., TeamsChannel) does not touch any abstraction code.
  • Refining the abstraction (e.g., ScheduledReportService) does not touch any channel code.
  • The connection is established only at the composition root (startup or DI).

The two hierarchies remain independently extendable.

When to Use It

  • When a class needs to vary in two independent dimensions.
  • When you want to share an implementation between multiple abstractions.
  • When switching implementations at runtime is a requirement.
  • When inheritance would create an unmanageable number of subclass combinations.

Avoid Bridge when only one dimension changes or when the codebase is small enough that simple inheritance is clearer.

Important Note for ASP.NET Core

In ASP.NET Core, Bridge maps naturally to dependency injection. The abstraction and implementor are registered separately, and the container wires them at startup.

builder.Services.AddScoped<IReportChannel, EmailChannel>();
builder.Services.AddScoped<ReportService>();

To switch implementations per environment, use a factory or named registration. The abstraction layer never changes β€” only the registered implementor does.

Core Components of the Pattern

PartPurposeExample in this article
AbstractionHigh-level business operationsReportService
Refined AbstractionExtended abstraction with specialised behaviorScheduledReportService
ImplementorLow-level contract decoupled from abstractionIReportChannel
Concrete ImplementorActual delivery or processing logicEmailChannel, SlackChannel
ClientComposes abstraction and implementorAPI controller / startup

Common Bridge Variations

01-AbstractionImplementationSeparation

This variation demonstrates the core Bridge principle: the abstraction holds the implementor, and the implementor uses the Factory Method pattern internally to produce the output object.

Factory Method is used only here to show how the implementor side can encapsulate creation logic independently.

// Implementor contract - internally uses Factory Method to produce documents
public interface IDocumentCreator
{
string Create(); // Factory Method declared here, defined in subclasses
}
// Concrete Implementor A - overrides the Factory Method
public sealed class PdfDocumentCreator : IDocumentCreator
{
public string Create() => "PDF document created via Factory Method";
}
// Concrete Implementor B - overrides the Factory Method
public sealed class WordDocumentCreator : IDocumentCreator
{
public string Create() => "Word document created via Factory Method";
}
// Abstraction - holds the implementor via Bridge composition
public class OrderReportService
{
protected readonly IDocumentCreator _creator;
public OrderReportService(IDocumentCreator creator)
{
_creator = creator;
}
public virtual string GenerateReport()
{
return $"Order Report -> {_creator.Create()}";
}
}
  • Bridge role: OrderReportService is the abstraction; IDocumentCreator is the implementor interface.
  • Factory Method role: PdfDocumentCreator and WordDocumentCreator each define their own Create() β€” the Factory Method.
  • The abstraction never knows which Factory Method subclass it is working with.

02-RefinedAbstractionVsConcreteImplementor

This variation introduces a refined abstraction while keeping the Factory Method-based concrete implementors completely independent.

Factory Method is used only here, inside the implementors, and the refined abstraction adds specialised behavior without touching creation logic.

// Refined Abstraction - extends OrderReportService without touching implementors
public sealed class PriorityOrderReportService : OrderReportService
{
public PriorityOrderReportService(IDocumentCreator creator)
: base(creator) { }
public override string GenerateReport()
{
return $"[PRIORITY] {_creator.Create()}";
}
}
// Runtime wiring - Bridge links abstraction and implementor at the composition root
var standardReport = new OrderReportService(new PdfDocumentCreator());
var priorityReport = new PriorityOrderReportService(new WordDocumentCreator());
Console.WriteLine(standardReport.GenerateReport());
// Output: Order Report -> PDF document created via Factory Method
Console.WriteLine(priorityReport.GenerateReport());
// Output: [PRIORITY] Word document created via Factory Method

Key points:

  • PriorityOrderReportService is the refined abstraction β€” it specialises GenerateReport without knowing how documents are created.
  • PdfDocumentCreator and WordDocumentCreator are concrete implementors using Factory Method.
  • Swapping PdfDocumentCreator for WordDocumentCreator (or any new implementor) in either abstraction requires zero code changes in the abstraction layer.

How the Variations Differ

VariationWhat it showsKey takeaway
01-AbstractionImplementationSeparationBase abstraction bridged to Factory Method implementorsThe abstraction depends only on the implementor contract
02-RefinedAbstractionVsConcreteImplementorExtended abstraction with independent implementorsBoth hierarchies grow without touching each other

SOLID Principles Behind It

SRP - Single Responsibility Principle

The abstraction owns business-level behavior. The implementor owns low-level mechanics. Each changes for a different reason.

OCP - Open/Closed Principle

New channels or report types can be added by creating new classes. Existing abstractions and implementors stay closed for modification.

LSP - Liskov Substitution Principle

Any IReportChannel can replace another without breaking ReportService. Any refined abstraction can replace its base without changing callers.

DIP - Dependency Inversion Principle

ReportService depends on the IReportChannel abstraction, not on any concrete channel. DI injects the concrete type at the composition root.

Advantages and Disadvantages

Advantages

  • Decouples abstraction from implementation completely.
  • Supports runtime switching of implementors.
  • Prevents combinatorial class explosion from inheritance.
  • Both hierarchies can be extended independently.

Disadvantages

  • Adds structural complexity for simple scenarios.
  • Requires a well-defined split between abstraction and implementation; unclear boundaries cause confusion.
  • More upfront design thinking is needed compared to simple inheritance.

UML Diagram

classDiagram
class Client
class ReportService {
-IReportChannel channel
+Send(report) void
}
class ScheduledReportService {
+Send(report) void
}
class IReportChannel {
<<interface>>
+Deliver(content) void
}
class EmailChannel {
+Deliver(content) void
}
class SlackChannel {
+Deliver(content) void
}
ReportService <|-- ScheduledReportService : extends
ReportService --> IReportChannel : bridge reference
IReportChannel <|.. EmailChannel : implements
IReportChannel <|.. SlackChannel : implements
Client --> ReportService : uses

A Quick Usage Example

// Compose abstraction and implementor at the call site or DI root
IReportChannel channel = new EmailChannel();
var service = new ReportService(channel);
service.Send("Monthly revenue report");
// Switch implementor without changing ReportService
IReportChannel slack = new SlackChannel();
var slackService = new ReportService(slack);
slackService.Send("Deployment status report");

The abstraction and channel are wired at the top. All business logic in ReportService stays unchanged regardless of which channel is injected.

How to Validate the Pattern

To confirm Bridge is implemented correctly, check these points:

  • the abstraction class must hold the implementor via an interface field, not a concrete type
  • removing or replacing an implementor class must not require changes to any abstraction class
  • adding a new refined abstraction must not require changes to any implementor class
  • the composition root is the only place that knows both concrete sides

A small xUnit test to verify the separation:

[Fact]
public void ReportService_Should_Delegate_To_Channel()
{
var fakeChannel = new FakeChannel();
var service = new ReportService(fakeChannel);
service.Send("Test report");
Assert.Equal("Test report", fakeChannel.LastDelivered);
}
public sealed class FakeChannel : IReportChannel
{
public string LastDelivered { get; private set; } = string.Empty;
public void Deliver(string content) => LastDelivered = content;
}
Share:
Back to Blog

Related Posts

View All Posts Β»