Bridge Design Pattern in Java
Think of a TV remote control. The remote decides what you can do: power on, volume up, mute. The TV decides how each action is carried out. You can buy a new remote without changing the TV, or a new TV that works with the same remote. The Bridge Design Pattern gives your Java classes the same freedom.
This tutorial explains what the Bridge pattern is, the problem it solves, its structure, and four complete Java examples. It also shows where the JDK uses it, how it differs from Adapter and Strategy, and when you should and should not use it.
1. What Is the Bridge Design Pattern?
The Bridge Design Pattern is a structural design pattern that separates an abstraction from its implementation so that the two can vary independently.
In simple words, it splits one large class, or a set of closely related classes, into two separate hierarchies:
- Abstraction: the high-level part the client talks to (the remote control).
- Implementation: the low-level part that does the actual work (the TV or radio).
Instead of linking them through inheritance, the abstraction holds a reference to an implementation object and delegates work to it. That reference is the bridge. This is the principle of composition over inheritance applied to two dimensions at once.
2. The Problem: Class Explosion
Without the Bridge pattern, code that varies in two directions quickly becomes hard to maintain. Suppose you have:
- Different shapes:
Circle,Square - Different colors:
Red,Blue
If you use inheritance for every combination, you end up with RedCircle, BlueCircle, RedSquare and BlueSquare. Add a Triangle and you need two more classes. Add Green and you need three more. The number of classes is the product of the two dimensions.
With the Bridge pattern you write one class per shape and one class per color, so the number of classes is the sum:
| Shapes | Colors | Inheritance (shapes × colors) | Bridge (shapes + colors) |
|---|---|---|---|
| 2 | 2 | 4 classes | 4 classes |
| 3 | 3 | 9 classes | 6 classes |
| 5 | 4 | 20 classes | 9 classes |
| 10 | 5 | 50 classes | 15 classes |
The Bridge pattern solves this problem by:
- Keeping shape logic in its own hierarchy
- Keeping color logic in its own hierarchy
- Connecting the two with a single reference, the bridge
3. Key Components and Structure
| Component | Role | In the shapes example |
|---|---|---|
| Abstraction | Defines the high-level interface for the client and holds a reference to an Implementor | Shape |
| Refined Abstraction | Extends the Abstraction with specific variants or extra behavior | Circle, Square |
| Implementor | Interface for the low-level operations the Abstraction relies on | Color |
| Concrete Implementor | Provides the actual platform- or detail-specific implementation | Red, Blue |
| Client | Picks a concrete implementor, passes it to an abstraction and uses the abstraction | BridgePatternDemo |
ABSTRACTION SIDE IMPLEMENTATION SIDE
+-----------------------+ the bridge +-----------------------+
| Shape (abstract) |------------------>| <<interface>> Color |
|-----------------------| has-a |-----------------------|
| # color : Color | | + fill() : String |
| + draw() | +-----------------------+
+-----------------------+ ^
^ | implements
| extends +-------+-------+
+------+------+ | |
| | Red Blue
Circle Square (Concrete Implementors)
(Refined Abstractions)
Notice that Circle never extends Red. A circle has a color; it is not a kind of color. Each hierarchy can grow without touching the other.
4. Bridge Pattern Examples in Java
The four examples below apply the same structure to different problems. Select a tab to switch between them; each one is complete and runnable on its own.
Example 1: Shapes and Colors
Let us fix the class explosion from section 2, step by step.
Step 1: Create the Implementor Interface
public interface Color {
String fill();
}
Step 2: Create the Concrete Implementors
public class Red implements Color {
@Override
public String fill() {
return "red";
}
}
public class Blue implements Color {
@Override
public String fill() {
return "blue";
}
}
Step 3: Create the Abstraction
The abstraction holds the bridge: a Color reference passed in through the constructor.
public abstract class Shape {
// The bridge: a reference to the implementation hierarchy
protected final Color color;
protected Shape(Color color) {
this.color = color;
}
public abstract void draw();
}
Step 4: Create the Refined Abstractions
public class Circle extends Shape {
private final int radius;
public Circle(int radius, Color color) {
super(color);
this.radius = radius;
}
@Override
public void draw() {
System.out.println("Drawing a circle of radius " + radius
+ " filled with " + color.fill());
}
}
public class Square extends Shape {
private final int side;
public Square(int side, Color color) {
super(color);
this.side = side;
}
@Override
public void draw() {
System.out.println("Drawing a square of side " + side
+ " filled with " + color.fill());
}
}
Step 5: Test the Bridge Pattern
public class BridgePatternDemo {
public static void main(String[] args) {
Shape redCircle = new Circle(5, new Red());
Shape blueCircle = new Circle(8, new Blue());
Shape blueSquare = new Square(4, new Blue());
redCircle.draw();
blueCircle.draw();
blueSquare.draw();
}
}
Output
Drawing a circle of radius 5 filled with red
Drawing a circle of radius 8 filled with blue
Drawing a square of side 4 filled with blue
Green color. You write one new class, and every existing shape can use it immediately. With inheritance you would need GreenCircle and GreenSquare.
Example 2: Remote Controls and Devices
This is the classic Bridge example. Remotes form the abstraction hierarchy (basic and advanced), and devices form the implementation hierarchy (TV and radio). Any remote works with any device.
Step 1: The Implementor (Device)
public interface Device {
boolean isEnabled();
void enable();
void disable();
int getVolume();
void setVolume(int volume);
}
Step 2: Concrete Implementors (Tv and Radio)
public class Tv implements Device {
private boolean on;
private int volume = 30;
@Override
public boolean isEnabled() {
return on;
}
@Override
public void enable() {
on = true;
System.out.println("TV is ON");
}
@Override
public void disable() {
on = false;
System.out.println("TV is OFF");
}
@Override
public int getVolume() {
return volume;
}
@Override
public void setVolume(int volume) {
this.volume = Math.max(0, Math.min(100, volume));
System.out.println("TV volume set to " + this.volume);
}
}
public class Radio implements Device {
private boolean on;
private int volume = 20;
@Override
public boolean isEnabled() {
return on;
}
@Override
public void enable() {
on = true;
System.out.println("Radio is ON");
}
@Override
public void disable() {
on = false;
System.out.println("Radio is OFF");
}
@Override
public int getVolume() {
return volume;
}
@Override
public void setVolume(int volume) {
this.volume = Math.max(0, Math.min(100, volume));
System.out.println("Radio volume set to " + this.volume);
}
}
Step 3: The Abstraction (RemoteControl)
public class RemoteControl {
protected final Device device;
public RemoteControl(Device device) {
this.device = device;
}
public void togglePower() {
if (device.isEnabled()) {
device.disable();
} else {
device.enable();
}
}
public void volumeUp() {
device.setVolume(device.getVolume() + 10);
}
public void volumeDown() {
device.setVolume(device.getVolume() - 10);
}
}
Step 4: The Refined Abstraction (AdvancedRemoteControl)
The advanced remote adds a feature using only the Device interface, so it works with every device, including ones that do not exist yet.
public class AdvancedRemoteControl extends RemoteControl {
public AdvancedRemoteControl(Device device) {
super(device);
}
public void mute() {
device.setVolume(0);
}
}
Step 5: Client Code
public class RemoteDemo {
public static void main(String[] args) {
RemoteControl basicRemote = new RemoteControl(new Tv());
basicRemote.togglePower();
basicRemote.volumeUp();
AdvancedRemoteControl advancedRemote = new AdvancedRemoteControl(new Radio());
advancedRemote.togglePower();
advancedRemote.volumeUp();
advancedRemote.mute();
}
}
Output
TV is ON
TV volume set to 40
Radio is ON
Radio volume set to 30
Radio volume set to 0
Example 3: Notifications and Senders
A real application often has several kinds of notification (alerts, reminders) and several delivery channels (email, SMS). These are two independent dimensions, which makes them a natural fit for the Bridge pattern.
Implementor and Concrete Implementors
public interface MessageSender {
void send(String recipient, String message);
}
public class EmailSender implements MessageSender {
@Override
public void send(String recipient, String message) {
System.out.println("Email to " + recipient + ": " + message);
}
}
public class SmsSender implements MessageSender {
@Override
public void send(String recipient, String message) {
System.out.println("SMS to " + recipient + ": " + message);
}
}
Abstraction and Refined Abstractions
import java.util.Objects;
public abstract class Notification {
protected final MessageSender sender;
protected Notification(MessageSender sender) {
this.sender = Objects.requireNonNull(sender, "sender must not be null");
}
public abstract void deliver(String recipient, String details);
}
public class AlertNotification extends Notification {
public AlertNotification(MessageSender sender) {
super(sender);
}
@Override
public void deliver(String recipient, String details) {
sender.send(recipient, "[ALERT] " + details.toUpperCase());
}
}
public class ReminderNotification extends Notification {
public ReminderNotification(MessageSender sender) {
super(sender);
}
@Override
public void deliver(String recipient, String details) {
sender.send(recipient, "Reminder: " + details);
}
}
Client Code and Output
public class NotificationDemo {
public static void main(String[] args) {
Notification serverAlert = new AlertNotification(new SmsSender());
serverAlert.deliver("+91-9876543210", "CPU usage above 90%");
Notification meetingReminder = new ReminderNotification(new EmailSender());
meetingReminder.deliver("dev-team@example.com", "Sprint review at 4 PM");
}
}
SMS to +91-9876543210: [ALERT] CPU USAGE ABOVE 90%
Email to dev-team@example.com: Reminder: Sprint review at 4 PM
Adding a PushSender or a MarketingNotification is now a one-class change. Neither hierarchy needs to know the other exists.
EmailSender and SmsSender would be beans implementing MessageSender, and the notification classes would receive one through constructor injection, selected with @Qualifier or a configuration property. That is the Bridge pattern wired by the container.
Example 4: Reports and Export Formats
Business applications often produce several reports (sales, inventory) in several output formats (CSV, plain text, PDF). Without a bridge you would write SalesCsvReport, SalesTextReport, InventoryCsvReport and so on. Here the report decides what data to show, and the formatter decides how it looks.
Step 1: The Implementor (ReportFormatter)
public interface ReportFormatter {
String title(String title);
String row(String... cells);
}
Step 2: Concrete Implementors (CSV and Plain Text)
public class CsvFormatter implements ReportFormatter {
@Override
public String title(String title) {
return "# " + title;
}
@Override
public String row(String... cells) {
return String.join(",", cells);
}
}
public class TextFormatter implements ReportFormatter {
@Override
public String title(String title) {
return title.toUpperCase() + System.lineSeparator() + "=".repeat(title.length());
}
@Override
public String row(String... cells) {
StringBuilder line = new StringBuilder();
for (String cell : cells) {
line.append(String.format("%-14s", cell));
}
return line.toString().stripTrailing();
}
}
Step 3: The Abstraction (Report)
generate() builds the report using only the two primitive operations of the formatter, so it works with any format.
import java.util.List;
import java.util.Objects;
public abstract class Report {
protected final ReportFormatter formatter;
protected Report(ReportFormatter formatter) {
this.formatter = Objects.requireNonNull(formatter, "formatter must not be null");
}
public String generate() {
String newLine = System.lineSeparator();
StringBuilder output = new StringBuilder();
output.append(formatter.title(title())).append(newLine);
output.append(formatter.row(columns())).append(newLine);
for (String[] row : rows()) {
output.append(formatter.row(row)).append(newLine);
}
return output.toString();
}
protected abstract String title();
protected abstract String[] columns();
protected abstract List<String[]> rows();
}
Step 4: Refined Abstractions (SalesReport and InventoryReport)
import java.util.List;
public class SalesReport extends Report {
public SalesReport(ReportFormatter formatter) {
super(formatter);
}
@Override
protected String title() {
return "Monthly Sales";
}
@Override
protected String[] columns() {
return new String[] {"Month", "Orders", "Revenue"};
}
@Override
protected List<String[]> rows() {
return List.of(
new String[] {"January", "120", "48000"},
new String[] {"February", "135", "52500"});
}
}
import java.util.List;
public class InventoryReport extends Report {
public InventoryReport(ReportFormatter formatter) {
super(formatter);
}
@Override
protected String title() {
return "Stock Levels";
}
@Override
protected String[] columns() {
return new String[] {"Product", "In Stock"};
}
@Override
protected List<String[]> rows() {
return List.of(
new String[] {"Keyboard", "42"},
new String[] {"Monitor", "7"});
}
}
Step 5: Client Code
public class ReportDemo {
public static void main(String[] args) {
Report salesAsCsv = new SalesReport(new CsvFormatter());
System.out.println(salesAsCsv.generate());
Report inventoryAsText = new InventoryReport(new TextFormatter());
System.out.println(inventoryAsText.generate());
}
}
Output
# Monthly Sales
Month,Orders,Revenue
January,120,48000
February,135,52500
STOCK LEVELS
============
Product In Stock
Keyboard 42
Monitor 7
MarkdownFormatter that returns "| " + String.join(" | ", cells) + " |". Both reports can export to Markdown immediately, and neither report class changes.
5. Choosing the Implementation at Runtime
Because the bridge is a plain object reference, the client can pick the implementor from configuration, user input or environment, without any if chains inside the abstraction:
public class SenderSelection {
static MessageSender senderFor(String channel) {
switch (channel) {
case "sms":
return new SmsSender();
case "email":
return new EmailSender();
default:
throw new IllegalArgumentException("Unknown channel: " + channel);
}
}
public static void main(String[] args) {
String channel = System.getProperty("notify.channel", "email");
Notification reminder = new ReminderNotification(senderFor(channel));
reminder.deliver("user@example.com", "Your trial ends tomorrow");
}
}
Run it with -Dnotify.channel=sms and the same ReminderNotification sends an SMS instead of an email. Keeping the bridge field final and choosing the implementor at construction time, as shown here, is usually safer than exposing a setter.
6. Bridge Pattern in the JDK: JDBC
JDBC is the best-known Bridge in Java. Your code works against the java.sql interfaces (the abstraction), and each database vendor ships a driver that implements them (the implementation):
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class UserRepository {
private final String url;
private final String user;
private final String password;
public UserRepository(String url, String user, String password) {
this.url = url;
this.user = user;
this.password = password;
}
public String findNameById(long id) throws SQLException {
String sql = "SELECT name FROM users WHERE id = ?";
try (Connection connection = DriverManager.getConnection(url, user, password);
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, id);
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next() ? resultSet.getString("name") : null;
}
}
}
}
Change the URL from jdbc:mysql://... to jdbc:postgresql://... and add the PostgreSQL driver to the classpath. UserRepository does not change. Other examples of the same idea include SLF4J with Logback or Log4j 2 bindings, and AWT components with their native peers.
7. Bridge vs Adapter vs Strategy
These patterns all use composition and can look alike in code. The difference is intent:
| Aspect | Bridge | Adapter | Strategy |
|---|---|---|---|
| Category | Structural | Structural | Behavioral |
| Intent | Let abstraction and implementation vary independently | Make an incompatible interface work with existing code | Swap one algorithm inside a class |
| When applied | Designed up front | Applied after the fact to existing classes | Designed up front |
| Hierarchies | Two, both may have subclasses | Adapter wraps one adaptee | One context, many strategies |
| Typical example | Remote and device, JDBC | Arrays.asList, InputStreamReader | Comparator passed to a sort |
A quick rule: if the wrapper side also has its own subclasses that vary independently, it is a Bridge. If only the behavior is swapped, it is a Strategy. If you are fixing a mismatch between existing interfaces, it is an Adapter.
8. Advantages and Disadvantages
Advantages
- Prevents class explosion: m + n classes instead of m × n.
- Independent extension: add abstractions or implementations without touching the other side (Open/Closed Principle).
- Single Responsibility: high-level logic and platform details live in separate classes.
- Hides implementation details from the client, which only sees the abstraction.
- Easier testing: pass a fake or mock implementor to test the abstraction in isolation.
- Runtime flexibility: choose the implementation from configuration.
Disadvantages
- More types and indirection, which can feel heavy for small problems.
- Harder to design up front: you must identify the two independent dimensions correctly.
- Overkill when there is only one implementation or only one dimension of change.
9. When to Use the Bridge Pattern
Use it when:
- A class varies in two or more independent dimensions, such as type and platform, or message and channel.
- You want to avoid a permanent binding between an abstraction and its implementation.
- Changes to the implementation should not force changes or recompilation in client code.
- You need to support several platforms, such as databases, operating systems, rendering engines or cloud providers.
- You want to switch implementations through configuration.
Avoid it when there is only one implementation and no realistic plan for another, or when a simple Strategy covers the need.
10. Best Practices and Common Mistakes
Best practices
- Keep the Implementor interface small and made of primitive operations. Let the abstraction combine them into high-level behavior, as
mute()does withsetVolume(0). - Inject the implementor through the constructor and store it in a
finalfield. - Validate the implementor with
Objects.requireNonNullso misconfiguration fails fast. - Combine with a Factory or Abstract Factory to create the right implementor for an environment.
Common mistakes
- Leaking implementation details into the abstraction, for example with
instanceof Tvchecks, which ties the two hierarchies back together. - Mirroring hierarchies: creating
TvRemoteandRadioRemoterecreates the class explosion the pattern was meant to remove. - Applying it too early when only one implementation exists. Introduce the bridge when the second dimension appears.
11. Conclusion
The Bridge Design Pattern keeps your code clean, flexible and scalable by separating what a class does from how it does it. Two small hierarchies connected by a single reference replace a large grid of subclasses, and each side can grow without breaking the other.
You have seen it with shapes and colors, remotes and devices, notifications and senders, reports and export formats, and in the JDK with JDBC. It is especially useful in large applications where platforms and features change often. Continue with the Flyweight Design Pattern, or revisit the structural design patterns overview.