StackPractices
intermediate By Mathias Paulenko

Bridge Pattern: Decouple Abstraction from Implementation

Split a class into two hierarchies — abstraction and implementation — so both can evolve independently. Includes Python, Java, and JavaScript examples.

Topics: design

Overview

classDiagram diagram: class Shape {

The Bridge pattern is a structural design pattern that splits an abstraction from its implementation. Instead of one class hierarchy that mixes both, you get two — one for the abstraction and one for the implementation. That lets each side evolve on its own without breaking the other side.

I’ve reached for Bridge on projects where a UI framework had to support both Canvas and WebGL backends across a dozen widget types. Without Bridge, we’d have had CanvasButton, WebGLButton, CanvasSlider, WebGLSlider — a class explosion. With Bridge, each widget held a renderer reference and delegated the drawing. Adding a new renderer meant one new class, not a dozen.

A typical example: a UI with different widget shapes and different rendering backends. Without Bridge, every shape class would need to know about every renderer. With Bridge, a Circle holds a reference to a Renderer and delegates the drawing. You can add shapes or renderers later without breaking anything that already works. For a related take on adapting interfaces, check our Adapter pattern — the sibling people confuse Bridge with the most.

When to Use

  • You want to avoid a permanent binding between the abstraction and its implementation.
  • Both sides of the design should be extensible through subclassing.
  • Several objects need to share the same underlying implementation.
  • Changes to the implementation shouldn’t ripple out to clients.
  • You’re facing a class explosion because two dimensions, such as shapes and renderers, are combined into one hierarchy.

When NOT to Use

  • A simple Strategy or Adapter already covers a single dimension of variation.
  • The project is small and the extra hierarchy adds more complexity than value.
  • You control neither side of the abstraction/implementation split.

Solution

Python

from abc import ABC, abstractmethod

class Renderer(ABC):
    @abstractmethod
    def render_circle(self, radius: float):
        pass

class VectorRenderer(Renderer):
    def render_circle(self, radius: float):
        print(f"Drawing a circle of radius {radius} with vector graphics")

class RasterRenderer(Renderer):
    def render_circle(self, radius: float):
        print(f"Drawing pixels for a circle of radius {radius}")

class Shape(ABC):
    def __init__(self, renderer: Renderer):
        self.renderer = renderer

    @abstractmethod
    def draw(self):
        pass

class Circle(Shape):
    def __init__(self, renderer: Renderer, radius: float):
        super().__init__(renderer)
        self.radius = radius

    def draw(self):
        self.renderer.render_circle(self.radius)

circle_vector = Circle(VectorRenderer(), 5.0)
circle_vector.draw()

circle_raster = Circle(RasterRenderer(), 10.0)
circle_raster.draw()

JavaScript

class VectorRenderer {
  renderCircle(radius) {
    console.log(`Drawing a circle of radius ${radius} with vector graphics`);
  }
}

class RasterRenderer {
  renderCircle(radius) {
    console.log(`Drawing pixels for a circle of radius ${radius}`);
  }
}

class Shape {
  constructor(renderer) {
    this.renderer = renderer;
  }
  draw() {
    throw new Error("Subclasses must implement draw()");
  }
}

class Circle extends Shape {
  constructor(renderer, radius) {
    super(renderer);
    this.radius = radius;
  }

  draw() {
    this.renderer.renderCircle(this.radius);
  }
}

const cv = new Circle(new VectorRenderer(), 5);
cv.draw();

const cr = new Circle(new RasterRenderer(), 10);
cr.draw();

Java

public interface Renderer {
    void renderCircle(double radius);
}

public class VectorRenderer implements Renderer {
    public void renderCircle(double radius) {
        System.out.println("Drawing a circle of radius " + radius + " with vector graphics");
    }
}

public class RasterRenderer implements Renderer {
    public void renderCircle(double radius) {
        System.out.println("Drawing pixels for a circle of radius " + radius);
    }
}

public abstract class Shape {
    protected final Renderer renderer;

    public Shape(Renderer renderer) {
        this.renderer = renderer;
    }

    public abstract void draw();
}

public class Circle extends Shape {
    private final double radius;

    public Circle(Renderer renderer, double radius) {
        super(renderer);
        this.radius = radius;
    }

    public void draw() {
        renderer.renderCircle(radius);
    }
}

Shape cv = new Circle(new VectorRenderer(), 5.0);
cv.draw();

Explanation

The pattern separates two dimensions into two class hierarchies:

  • Abstraction (Shape): the high-level interface clients use.
  • Implementation (Renderer): the low-level operations that carry out the work.

The abstraction holds a reference to the implementation and delegates work to it. That separation means you can add new shapes or new renderers without touching code that already works.

Variants

VariantDescriptionUse Case
Classic BridgeTwo parallel hierarchiesShapes and renderers, devices and drivers
Driver BridgeAbstraction over hardware or OS APIsCross-platform UI frameworks
Remote BridgeLocal abstraction over remote implementationRPC stubs and proxies

Cross-platform rendering in TypeScript

interface Renderer {
  renderCircle(x: number, y: number, r: number): string;
  renderRect(x: number, y: number, w: number, h: number): string;
}

class SVGRenderer implements Renderer {
  renderCircle(x, y, r) { return `<circle cx="${x}" cy="${y}" r="${r}" />`; }
  renderRect(x, y, w, h) { return `<rect x="${x}" y="${y}" width="${w}" height="${h}" />`; }
}

class CanvasRenderer implements Renderer {
  renderCircle(x, y, r) { return `ctx.arc(${x}, ${y}, ${r}, 0, Math.PI * 2); ctx.stroke();`; }
  renderRect(x, y, w, h) { return `ctx.strokeRect(${x}, ${y}, ${w}, ${h});`; }
}

abstract class Shape {
  constructor(protected renderer: Renderer) {}
  abstract draw(): string;
}

class Circle extends Shape {
  constructor(renderer: Renderer, private x: number, private y: number, private r: number) {
    super(renderer);
  }
  draw() { return this.renderer.renderCircle(this.x, this.y, this.r); }
}

const svgCircle = new Circle(new SVGRenderer(), 50, 50, 20);
const canvasCircle = new Circle(new CanvasRenderer(), 50, 50, 20);

console.log(svgCircle.draw());    // SVG circle
console.log(canvasCircle.draw()); // Canvas circle

Remote Bridge with a local proxy

Remote Bridge puts the implementation on the other side of a network call. The abstraction lives locally and delegates to a proxy that handles the wire protocol. I’ve reached for this pattern to wrap gRPC services so application code can call them like local objects.

import json
import urllib.request

class RemoteRenderer:
    """Local abstraction over a remote rendering service."""
    def __init__(self, endpoint: str):
        self.endpoint = endpoint

    def render_circle(self, radius: float):
        payload = json.dumps({"shape": "circle", "radius": radius}).encode()
        req = urllib.request.Request(
            f"{self.endpoint}/render",
            data=payload,
            headers={"Content-Type": "application/json"},
        )
        with urllib.request.urlopen(req) as resp:
            return resp.read().decode()

# Application code doesn't know the renderer is remote
remote = RemoteRenderer("https://render.example.com")
circle = Circle(remote, 7.5)
circle.draw()  # Calls the remote service transparently

The beauty here is that Circle doesn’t change at all — it still calls self.renderer.render_circle(self.radius). The remote complexity is hidden inside the implementation, not the abstraction.

Best Practices

  • Identify the independent dimensions before applying the pattern. Not every multi-hierarchy problem needs a Bridge.
  • Keep the implementation interface minimal. Expose only what the abstraction actually needs.
  • Prefer composition over inheritance. The Bridge pattern is about composition.
  • Use dependency injection to wire implementations into abstractions.
  • Document which side is the abstraction and which side is the implementation.

Common Mistakes

  • Applying Bridge when a simple Strategy or Adapter would suffice.
  • Making the implementation interface too broad and coupling it to the abstraction.
  • Letting the abstraction leak implementation details to clients.
  • Creating deep hierarchies on both sides and reintroducing the complexity the pattern was meant to solve.

See Also

Frequently Asked Questions

What is the difference between Bridge and Adapter?

Adapter makes incompatible interfaces work together. Bridge separates an abstraction from its implementation so both can evolve independently.

When should I use Bridge instead of Strategy?

Strategy varies a single algorithm. Bridge separates two entire class hierarchies. Use Bridge when you've got two independent dimensions of variation.

Is this pattern suitable for small projects?

For small projects with few components, Bridge can add more complexity than it's worth. Start simple and pull it out when the pain gets real enough.

Can I partially apply this pattern?

Yes. Many teams adopt patterns incrementally. Start with the core idea and add sophistication only where it's needed. The pattern is a guide, not a blueprint.

Why is Bridge sometimes confused with Strategy?

Both use composition to delegate work. The difference is scope: Strategy swaps one algorithm inside a single class, while Bridge separates two entire hierarchies. If you're only varying one thing, reach for Strategy. If you're varying two independent things, Bridge is the right tool.

Does Bridge work with dependency injection frameworks?

Yes, and it plays nice with them. Spring, Dagger, and similar DI containers can wire the implementation into the abstraction at runtime. That turns swapping renderers or backends into a config tweak, not a refactor.