Bridge Pattern — Deep Dive
Full bridge implementation
Here’s a complete bridge separating notification types from delivery mechanisms:
from typing import Protocol
from dataclasses import dataclass
from abc import ABC, abstractmethod
# Implementation hierarchy — HOW to deliver
class DeliveryChannel(Protocol):
def deliver(self, recipient: str, title: str, body: str) -> bool: ...
class EmailChannel:
def __init__(self, smtp_host: str = "smtp.example.com"):
self._host = smtp_host
def deliver(self, recipient: str, title: str, body: str) -> bool:
# SMTP logic here
print(f"Email to {recipient}: [{title}] {body[:50]}...")
return True
class SMSChannel:
def __init__(self, api_key: str = ""):
self._key = api_key
def deliver(self, recipient: str, title: str, body: str) -> bool:
# SMS API call here
truncated = body[:160]
print(f"SMS to {recipient}: {truncated}")
return True
class PushChannel:
def deliver(self, recipient: str, title: str, body: str) -> bool:
print(f"Push to {recipient}: {title}")
return True
# Abstraction hierarchy — WHAT to send
class Notification(ABC):
def __init__(self, channel: DeliveryChannel):
self._channel = channel
@abstractmethod
def send(self, recipient: str) -> bool:
...
def switch_channel(self, channel: DeliveryChannel) -> None:
"""Runtime channel swapping — the bridge in action."""
self._channel = channel
class AlertNotification(Notification):
def __init__(self, channel: DeliveryChannel, severity: str, message: str):
super().__init__(channel)
self.severity = severity
self.message = message
def send(self, recipient: str) -> bool:
title = f"🚨 [{self.severity.upper()}] Alert"
return self._channel.deliver(recipient, title, self.message)
class ReminderNotification(Notification):
def __init__(self, channel: DeliveryChannel, event: str, when: str):
super().__init__(channel)
self.event = event
self.when = when
def send(self, recipient: str) -> bool:
title = f"⏰ Reminder: {self.event}"
body = f"Don't forget: {self.event} at {self.when}"
return self._channel.deliver(recipient, title, body)
class ReportNotification(Notification):
def __init__(self, channel: DeliveryChannel, report_name: str, data: dict):
super().__init__(channel)
self.report_name = report_name
self.data = data
def send(self, recipient: str) -> bool:
title = f"📊 {self.report_name}"
body = "\n".join(f" {k}: {v}" for k, v in self.data.items())
return self._channel.deliver(recipient, title, body)
# Usage — mix and match freely
email = EmailChannel()
sms = SMSChannel(api_key="sk-123")
alert = AlertNotification(email, "critical", "Database CPU at 98%")
alert.send("ops@example.com")
# Switch to SMS at runtime
alert.switch_channel(sms)
alert.send("+1555000123")
Adding a new notification type requires one new class. Adding a new channel requires one new class. Neither side needs to know about the other’s changes.
Bridge with Python protocols
Using Protocol instead of abstract base classes gives structural subtyping — implementations don’t need to inherit from anything:
from typing import Protocol
class Renderer(Protocol):
def render_circle(self, x: float, y: float, radius: float) -> str: ...
def render_rect(self, x: float, y: float, w: float, h: float) -> str: ...
class SVGRenderer:
def render_circle(self, x: float, y: float, radius: float) -> str:
return f'<circle cx="{x}" cy="{y}" r="{radius}"/>'
def render_rect(self, x: float, y: float, w: float, h: float) -> str:
return f'<rect x="{x}" y="{y}" width="{w}" height="{h}"/>'
class CanvasRenderer:
def render_circle(self, x: float, y: float, radius: float) -> str:
return f"ctx.arc({x}, {y}, {radius}, 0, 2*Math.PI); ctx.fill();"
def render_rect(self, x: float, y: float, w: float, h: float) -> str:
return f"ctx.fillRect({x}, {y}, {w}, {h});"
class Shape:
def __init__(self, renderer: Renderer):
self._renderer = renderer
def draw(self) -> str:
raise NotImplementedError
class Circle(Shape):
def __init__(self, renderer: Renderer, x: float, y: float, radius: float):
super().__init__(renderer)
self.x, self.y, self.radius = x, y, radius
def draw(self) -> str:
return self._renderer.render_circle(self.x, self.y, self.radius)
class Rectangle(Shape):
def __init__(self, renderer: Renderer, x: float, y: float, w: float, h: float):
super().__init__(renderer)
self.x, self.y, self.w, self.h = x, y, w, h
def draw(self) -> str:
return self._renderer.render_rect(self.x, self.y, self.w, self.h)
Mypy verifies that SVGRenderer and CanvasRenderer satisfy the Renderer protocol without explicit registration.
Bridge in data pipelines
A practical use case: separating data transformation logic from storage backends.
from typing import Protocol, Any
class StorageBackend(Protocol):
def write(self, key: str, data: bytes) -> None: ...
def read(self, key: str) -> bytes: ...
def exists(self, key: str) -> bool: ...
class S3Storage:
def __init__(self, bucket: str):
self._bucket = bucket
def write(self, key: str, data: bytes) -> None:
# boto3 upload
pass
def read(self, key: str) -> bytes:
return b""
def exists(self, key: str) -> bool:
return False
class LocalStorage:
def __init__(self, base_path: str):
self._base = base_path
def write(self, key: str, data: bytes) -> None:
import os
path = os.path.join(self._base, key)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(data)
def read(self, key: str) -> bytes:
import os
with open(os.path.join(self._base, key), "rb") as f:
return f.read()
def exists(self, key: str) -> bool:
import os
return os.path.exists(os.path.join(self._base, key))
class DataPipeline:
"""Abstraction — pipeline logic is independent of storage."""
def __init__(self, storage: StorageBackend):
self._storage = storage
def process_batch(self, batch_id: str, records: list[dict]) -> int:
import json
processed = [self._transform(r) for r in records if self._validate(r)]
data = json.dumps(processed).encode()
self._storage.write(f"batches/{batch_id}.json", data)
return len(processed)
def _validate(self, record: dict) -> bool:
return "id" in record and "value" in record
def _transform(self, record: dict) -> dict:
return {**record, "processed": True}
In development, use LocalStorage. In production, use S3Storage. The pipeline logic doesn’t change. In tests, use an in-memory dict-based storage.
Runtime bridge swapping
One of Bridge’s advantages over inheritance: you can change the implementation at runtime.
class AdaptivePipeline(DataPipeline):
def process_batch(self, batch_id: str, records: list[dict]) -> int:
import json
processed = [self._transform(r) for r in records if self._validate(r)]
data = json.dumps(processed).encode()
# Try primary storage, fall back to secondary
try:
self._storage.write(f"batches/{batch_id}.json", data)
except Exception:
# Switch to fallback storage
self._storage = LocalStorage("/tmp/fallback")
self._storage.write(f"batches/{batch_id}.json", data)
return len(processed)
This is impossible with inheritance-based designs where the storage behavior is baked into the class hierarchy.
Bridge vs inheritance — the math
Consider M abstraction variants and N implementation variants:
- Inheritance: M × N concrete classes
- Bridge: M + N classes
| M (abstractions) | N (implementations) | Inheritance | Bridge |
|---|---|---|---|
| 3 | 3 | 9 | 6 |
| 5 | 4 | 20 | 9 |
| 10 | 6 | 60 | 16 |
The savings grow quadratically. At 10 × 6, you have 60 classes vs 16 — and every new variant on either side adds 1 class instead of M or N.
Anti-patterns
Premature bridging
Don’t create a bridge for a single implementation. If there’s only one renderer and you don’t expect more, the abstraction layer adds complexity without benefit. Apply the pattern when the second variation appears.
Leaky bridge
If the abstraction exposes implementation details (specific method signatures, error types), the bridge fails. The abstraction should define operations in domain terms, not implementation terms.
Bridge without protocols
In Python, always define the implementation interface explicitly (Protocol or ABC). Without it, duck typing works at runtime but gives no static analysis support, and the “contract” between abstraction and implementation is implicit.
The one thing to remember: Bridge Pattern turns an M × N class explosion into M + N by connecting two independent hierarchies through composition — use protocols to define the bridge contract and swap implementations freely at runtime.
See Also
- Python Adapter Pattern How Python's Adapter Pattern works like a travel power plug — making incompatible things work together.
- Python Builder Pattern Why building complex Python objects step by step beats cramming everything into one giant constructor.
- Python Composite Pattern How the Composite Pattern lets you treat a group of things the same way you'd treat a single thing in Python.
- Python Facade Pattern How the Facade Pattern gives you one simple button instead of a confusing control panel in Python.
- Python Flyweight Pattern How the Flyweight Pattern saves memory by sharing common data instead of copying it thousands of times.