Bridge Pattern in Python: Decoupled Hierarchies
You're building a drawing application. You have shapes—circles, squares, triangles. You also have rendering backends—vector graphics for print, raster for screen display. The naive approach creates a...
Key Insights
- The Bridge pattern prevents class explosion by separating “what you want to do” (abstraction) from “how you do it” (implementation), allowing both hierarchies to evolve independently.
- Python’s duck typing and Protocol classes make Bridge implementations cleaner than in statically-typed languages—you get the benefits without boilerplate interface declarations.
- Use Bridge when you identify two orthogonal dimensions of variation in your domain; if you’re creating classes like
EmailAlertNotificationandSlackAlertNotification, you’ve found a bridge waiting to happen.
The Problem of Exploding Class Hierarchies
You’re building a drawing application. You have shapes—circles, squares, triangles. You also have rendering backends—vector graphics for print, raster for screen display. The naive approach creates a class for each combination: VectorCircle, RasterCircle, VectorSquare, RasterSquare, and so on.
Three shapes times two renderers equals six classes. Add a third renderer? Nine classes. Fourth shape? Twelve classes. You’re watching a cartesian product unfold in your codebase.
This isn’t theoretical. I’ve seen notification systems with classes like EmailAlertNotification, SlackAlertNotification, EmailReminderNotification, SlackReminderNotification—the pattern repeating ad nauseam. Each new notification type or delivery channel multiplied the maintenance burden.
Think of remote controls and devices. A universal remote (abstraction) can control a TV, stereo, or streaming box (implementations). You don’t need a separate remote class for each device. The remote defines what operations exist (power, volume, channel); the device defines how those operations work. That’s the Bridge pattern.
Understanding the Bridge Pattern
The Bridge pattern separates an abstraction from its implementation so both can vary independently. The Gang of Four described it as “decoupling an abstraction from its implementation so that the two can vary independently.”
The structure involves four participants:
- Abstraction: Defines the interface and maintains a reference to an Implementor
- RefinedAbstraction: Extends the Abstraction interface
- Implementor: Defines the interface for implementation classes
- ConcreteImplementor: Implements the Implementor interface
The key insight is composition over inheritance. The Abstraction has an Implementor rather than being a specialized version of some base class.
Use Bridge when you have multiple orthogonal dimensions of variation. If you find yourself creating a matrix of classes where one axis represents “types of things” and another represents “ways of doing things,” Bridge is your answer.
Basic Implementation in Python
Let’s implement the shape-renderer example properly:
from abc import ABC, abstractmethod
class Renderer(ABC):
"""Implementation interface for rendering."""
@abstractmethod
def render_circle(self, radius: float) -> str:
pass
@abstractmethod
def render_square(self, side: float) -> str:
pass
class VectorRenderer(Renderer):
def render_circle(self, radius: float) -> str:
return f"Drawing circle as vector with radius {radius}"
def render_square(self, side: float) -> str:
return f"Drawing square as vector with side {side}"
class RasterRenderer(Renderer):
def __init__(self, dpi: int = 300):
self.dpi = dpi
def render_circle(self, radius: float) -> str:
pixels = int(radius * self.dpi)
return f"Drawing circle as {pixels}px raster bitmap"
def render_square(self, side: float) -> str:
pixels = int(side * self.dpi)
return f"Drawing square as {pixels}x{pixels}px raster bitmap"
class Shape(ABC):
"""Abstraction that bridges to a renderer."""
def __init__(self, renderer: Renderer):
self.renderer = renderer
@abstractmethod
def draw(self) -> str:
pass
class Circle(Shape):
def __init__(self, renderer: Renderer, radius: float):
super().__init__(renderer)
self.radius = radius
def draw(self) -> str:
return self.renderer.render_circle(self.radius)
class Square(Shape):
def __init__(self, renderer: Renderer, side: float):
super().__init__(renderer)
self.side = side
def draw(self) -> str:
return self.renderer.render_square(self.side)
# Usage
vector = VectorRenderer()
raster = RasterRenderer(dpi=150)
circle = Circle(vector, 5.0)
print(circle.draw()) # Drawing circle as vector with radius 5.0
square = Square(raster, 10.0)
print(square.draw()) # Drawing square as 1500x1500px raster bitmap
Adding a new shape requires one class. Adding a new renderer requires one class. No multiplication.
Python’s duck typing means you could skip the ABC entirely—any object with the right methods works. But explicit interfaces document intent and catch errors earlier.
Pythonic Variations
Python 3.8+ offers Protocol classes for structural subtyping. You get type checking benefits without forcing inheritance:
from typing import Protocol
class RendererProtocol(Protocol):
def render_circle(self, radius: float) -> str: ...
def render_square(self, side: float) -> str: ...
class SVGRenderer:
"""No inheritance needed—just implement the methods."""
def render_circle(self, radius: float) -> str:
return f'<circle r="{radius}"/>'
def render_square(self, side: float) -> str:
return f'<rect width="{side}" height="{side}"/>'
def create_shape(renderer: RendererProtocol) -> Shape:
# Type checker validates renderer has required methods
return Circle(renderer, 10.0)
For simple cases, first-class functions serve as lightweight implementations:
from typing import Callable
class FlexibleShape:
def __init__(self, draw_func: Callable[[float], str], size: float):
self.draw_func = draw_func
self.size = size
def draw(self) -> str:
return self.draw_func(self.size)
# Lambda as implementation
ascii_circle = FlexibleShape(
lambda r: "O" * int(r),
5.0
)
print(ascii_circle.draw()) # OOOOO
This works for trivial cases but loses the structure that makes Bridge valuable in larger systems.
Real-World Application: Notification System
Here’s where Bridge shines. A notification system has two clear dimensions: what you’re notifying about (alerts, reminders, reports) and how you deliver it (email, SMS, Slack, push):
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Protocol
@dataclass
class NotificationContent:
title: str
body: str
priority: str = "normal"
metadata: dict = None
class NotificationChannel(Protocol):
def send(self, recipient: str, content: NotificationContent) -> bool: ...
class EmailChannel:
def __init__(self, smtp_server: str):
self.smtp_server = smtp_server
def send(self, recipient: str, content: NotificationContent) -> bool:
subject = f"[{content.priority.upper()}] {content.title}"
print(f"Email to {recipient}: {subject}")
# Actual SMTP logic here
return True
class SlackChannel:
def __init__(self, webhook_url: str):
self.webhook_url = webhook_url
def send(self, recipient: str, content: NotificationContent) -> bool:
emoji = "🚨" if content.priority == "high" else "📢"
print(f"Slack to #{recipient}: {emoji} {content.title}")
# Actual webhook logic here
return True
class SMSChannel:
def __init__(self, api_key: str):
self.api_key = api_key
def send(self, recipient: str, content: NotificationContent) -> bool:
# SMS has character limits—truncate intelligently
message = f"{content.title}: {content.body[:100]}"
print(f"SMS to {recipient}: {message}")
return True
class Notification(ABC):
def __init__(self, channel: NotificationChannel):
self.channel = channel
@abstractmethod
def prepare_content(self) -> NotificationContent:
pass
def send(self, recipient: str) -> bool:
content = self.prepare_content()
return self.channel.send(recipient, content)
class AlertNotification(Notification):
def __init__(self, channel: NotificationChannel, message: str, severity: str):
super().__init__(channel)
self.message = message
self.severity = severity
def prepare_content(self) -> NotificationContent:
return NotificationContent(
title=f"ALERT: {self.severity}",
body=self.message,
priority="high" if self.severity == "critical" else "normal"
)
class ReminderNotification(Notification):
def __init__(self, channel: NotificationChannel, task: str, due_date: str):
super().__init__(channel)
self.task = task
self.due_date = due_date
def prepare_content(self) -> NotificationContent:
return NotificationContent(
title=f"Reminder: {self.task}",
body=f"Due: {self.due_date}",
priority="normal"
)
class ReportNotification(Notification):
def __init__(self, channel: NotificationChannel, report_name: str, data: dict):
super().__init__(channel)
self.report_name = report_name
self.data = data
def prepare_content(self) -> NotificationContent:
summary = ", ".join(f"{k}: {v}" for k, v in self.data.items())
return NotificationContent(
title=f"Report: {self.report_name}",
body=summary,
metadata=self.data
)
# Usage: mix and match freely
email = EmailChannel("smtp.company.com")
slack = SlackChannel("https://hooks.slack.com/...")
sms = SMSChannel("twilio-key")
# Same alert, different channels
alert = AlertNotification(email, "Server CPU at 95%", "critical")
alert.send("ops@company.com")
alert_slack = AlertNotification(slack, "Server CPU at 95%", "critical")
alert_slack.send("ops-alerts")
# Different notification types, same channel
reminder = ReminderNotification(slack, "Deploy v2.1", "Friday 5pm")
reminder.send("dev-team")
report = ReportNotification(email, "Weekly Metrics", {"users": 1500, "revenue": 25000})
report.send("stakeholders@company.com")
Adding a push notification channel? One class. Adding a digest notification type? One class. The bridge keeps them independent.
Bridge vs. Similar Patterns
| Aspect | Bridge | Adapter | Strategy |
|---|---|---|---|
| Intent | Separate abstraction from implementation upfront | Make incompatible interfaces work together | Define family of interchangeable algorithms |
| When designed | At architecture time | After the fact, to integrate existing code | When you need runtime algorithm selection |
| Relationship | Abstraction owns implementation | Adapter wraps adaptee | Context delegates to strategy |
| Hierarchies | Both can have hierarchies | Usually single adapter per adaptee | Strategies are typically flat |
The critical distinction: Bridge is designed upfront when you anticipate independent variation. Adapter retrofits compatibility. If you’re wrapping a third-party library to match your interface, that’s Adapter. If you’re architecting a system where notification types and delivery channels evolve separately, that’s Bridge.
Strategy is closer to Bridge but focuses on algorithms, not abstraction hierarchies. A sorting strategy doesn’t define a parallel abstraction—it’s just a swappable algorithm.
Testing and Best Practices
Bridge simplifies testing. Mock the implementation to test the abstraction in isolation:
import pytest
from unittest.mock import Mock
def test_alert_notification_prepares_high_priority_for_critical():
mock_channel = Mock()
mock_channel.send.return_value = True
alert = AlertNotification(mock_channel, "Disk full", "critical")
alert.send("admin@test.com")
mock_channel.send.assert_called_once()
call_args = mock_channel.send.call_args
content = call_args[0][1]
assert content.priority == "high"
assert "critical" in content.title
def test_reminder_notification_includes_due_date():
mock_channel = Mock()
mock_channel.send.return_value = True
reminder = ReminderNotification(mock_channel, "Review PR", "Tomorrow")
reminder.send("dev@test.com")
content = mock_channel.send.call_args[0][1]
assert "Tomorrow" in content.body
Guidelines for effective Bridge implementations:
-
Keep abstractions thin. The abstraction should define what, not how. Heavy logic belongs in implementations.
-
Favor constructor injection. Pass the implementation at construction time. Property injection makes the dependency less obvious.
-
Document the bridge relationship. Future maintainers need to understand why these hierarchies are separate. A docstring explaining “Notifications and Channels vary independently” saves confusion.
-
Don’t over-engineer. If you have one abstraction and one implementation, you don’t need Bridge. Wait until you have concrete evidence of multiple variations before introducing the pattern.
The Bridge pattern trades simplicity for flexibility. Use it when you’ve identified genuine orthogonal variation—not when you’re speculating about future requirements. When applied correctly, it transforms a multiplicative maintenance burden into an additive one.