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)InheritanceBridge
3396
54209
1066016

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.

pythondesign-patternsoop

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.