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.

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
ReportServicenever importsEmailChannelorSlackChanneldirectly.- 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
| Part | Purpose | Example in this article |
|---|---|---|
| Abstraction | High-level business operations | ReportService |
| Refined Abstraction | Extended abstraction with specialised behavior | ScheduledReportService |
| Implementor | Low-level contract decoupled from abstraction | IReportChannel |
| Concrete Implementor | Actual delivery or processing logic | EmailChannel, SlackChannel |
| Client | Composes abstraction and implementor | API 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 documentspublic interface IDocumentCreator{ string Create(); // Factory Method declared here, defined in subclasses}
// Concrete Implementor A - overrides the Factory Methodpublic sealed class PdfDocumentCreator : IDocumentCreator{ public string Create() => "PDF document created via Factory Method";}
// Concrete Implementor B - overrides the Factory Methodpublic sealed class WordDocumentCreator : IDocumentCreator{ public string Create() => "Word document created via Factory Method";}
// Abstraction - holds the implementor via Bridge compositionpublic class OrderReportService{ protected readonly IDocumentCreator _creator;
public OrderReportService(IDocumentCreator creator) { _creator = creator; }
public virtual string GenerateReport() { return $"Order Report -> {_creator.Create()}"; }}- Bridge role:
OrderReportServiceis the abstraction;IDocumentCreatoris the implementor interface. - Factory Method role:
PdfDocumentCreatorandWordDocumentCreatoreach define their ownCreate()β 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 implementorspublic 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 rootvar 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 MethodKey points:
PriorityOrderReportServiceis the refined abstraction β it specialisesGenerateReportwithout knowing how documents are created.PdfDocumentCreatorandWordDocumentCreatorare concrete implementors using Factory Method.- Swapping
PdfDocumentCreatorforWordDocumentCreator(or any new implementor) in either abstraction requires zero code changes in the abstraction layer.
How the Variations Differ
| Variation | What it shows | Key takeaway |
|---|---|---|
| 01-AbstractionImplementationSeparation | Base abstraction bridged to Factory Method implementors | The abstraction depends only on the implementor contract |
| 02-RefinedAbstractionVsConcreteImplementor | Extended abstraction with independent implementors | Both 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 : usesA Quick Usage Example
// Compose abstraction and implementor at the call site or DI rootIReportChannel channel = new EmailChannel();var service = new ReportService(channel);service.Send("Monthly revenue report");
// Switch implementor without changing ReportServiceIReportChannel 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;}


