Bridge Pattern #
Suppose you are building a notification system. There are three notification types — Alert, Reminder, and Report — and four delivery channels: Email, SMS, Push Notification, and Slack. With naive inheritance, you end up with twelve classes: AlertEmail, AlertSMS, AlertPush, AlertSlack, ReminderEmail, ReminderSMS, and so on. Now imagine a fifth channel gets added — WhatsApp — and you must add three more classes. Then a fourth notification type arrives, and suddenly there are twenty classes to maintain. This is called subclass explosion, and it is the problem the Bridge Pattern was designed to prevent. Instead of creating a class for every combination, the Bridge Pattern separates the two “dimensions” of change into two independent hierarchies connected through composition — so adding a new channel or a new type only requires adding one class, not as many as the other dimension has.
What Is the Bridge Pattern? #
The Bridge Pattern is a structural design pattern that separates abstraction from implementation into two separate class hierarchies, letting both evolve independently. The key word here is independently: a change on the abstraction side does not force a change on the implementation side, and vice versa.
In Go, which does not support classical inheritance, the Bridge Pattern is very natural because Go encourages composition over inheritance. The “bridge” is the interface connecting the abstraction to the implementation — not a middle-man class, but a simple contract that makes both sides interchangeable.
Three properties define the Bridge Pattern:
- Two hierarchies that evolve on their own — the abstraction and the implementation can each gain variations without knowing about each other
- Dependency injection as the “bridge” — the implementation is injected into the abstraction at construction time, not hardcoded
- Runtime combination — you can combine any compatible abstraction and implementation, even while the application is running
flowchart LR
subgraph "Without Bridge — Subclass Explosion"
direction TB
AE[AlertEmail]
AS[AlertSMS]
AP[AlertPush]
RE[ReminderEmail]
RS[ReminderSMS]
RP[ReminderPush]
note1["3 types × 3 channels\\n= 9 classes\\n\\nAdd 1 channel → +3 classes\\nAdd 1 type → +3 classes"]
end
subgraph "With Bridge — Independent"
direction TB
subgraph Abstraction
A[Alert]
R[Reminder]
end
subgraph Implementation
E[EmailSender]
S[SMSSender]
P[PushSender]
end
A & R -->|inject| E & S & P
note2["3 types + 3 channels = 6 classes\\n\\nAdd 1 channel → +1 class\\nAdd 1 type → +1 class"]
endTwo Axes of Change — The Core of Bridge #
The easiest way to detect whether the Bridge Pattern is needed: ask “are there two independent dimensions of change here?”. If the answer is yes, Bridge is almost always a strong candidate.
| Domain | Axis 1 (Abstraction) | Axis 2 (Implementation) |
|---|---|---|
| Notifications | Alert, Reminder, Report | Email, SMS, Push, Slack |
| Storage | LocalStorage, CloudStorage | FileSystem, S3, GCS, Azure |
| Shape rendering | Circle, Square, Triangle | SVG Renderer, Canvas Renderer |
| Remote control | BasicRemote, AdvancedRemote | TV, Radio, Projector |
| Database query | SimpleQuery, ComplexQuery | MySQL, PostgreSQL, MongoDB |
| Log writer | InfoLogger, ErrorLogger | FileLog, ConsoleLog, CloudLog |
Two axes are independent if adding a variation on one axis does not require changes on the other. Email can send an Alert or a Reminder without needing to know the difference — it only needs to know what to send, not why it is being sent.
Structure and Components #
The Bridge Pattern in Go involves four components working together.
classDiagram
class Notification {
<<interface>>
+Notify(message string) error
+SetSender(sender Sender)
}
class Sender {
<<interface>>
+Send(recipient, message string) error
+ChannelName() string
}
class AlertNotification {
-sender Sender
-priority string
+Notify(message string) error
}
class ReminderNotification {
-sender Sender
-scheduleTime string
+Notify(message string) error
}
class EmailSender {
-smtpHost string
+Send(recipient, message string) error
+ChannelName() string
}
class SMSSender {
-apiURL string
+Send(recipient, message string) error
+ChannelName() string
}
class PushSender {
-fcmKey string
+Send(recipient, message string) error
+ChannelName() string
}
Notification <|.. AlertNotification
Notification <|.. ReminderNotification
Sender <|.. EmailSender
Sender <|.. SMSSender
Sender <|.. PushSender
AlertNotification o-- Sender : bridge
ReminderNotification o-- Sender : bridge| Component | Role | Where It Grows |
|---|---|---|
| Abstraction interface | Contract for all notification types | New types added (Warning, Report) |
| Refined Abstraction | Concrete abstraction implementations | Alert, Reminder, Report, etc. |
| Implementor interface | Contract for all delivery channels | New channels added (WhatsApp) |
| Concrete Implementor | Concrete channel implementations | Email, SMS, Push, Slack, etc. |
Full Implementation: Storage Service #
A more complex case study than plain notifications — a storage service that can save various document types (Invoice, Report, Contract) to various backends (local filesystem, AWS S3, Google Cloud Storage). This is a Bridge Pattern you meet very often in enterprise applications.
Implementor Interface: StorageBackend #
package storage
import (
"context"
"io"
"time"
)
// ObjectMetadata stores information about a stored object.
type ObjectMetadata struct {
Key string
Size int64
ContentType string
CreatedAt time.Time
URL string // public URL if the backend supports it
}
// StorageBackend is the Implementor interface — the "bridge" between
// the document abstraction and concrete backends.
// Every backend must satisfy this contract.
type StorageBackend interface {
// Upload stores data in the backend under a given key.
Upload(ctx context.Context, key string, data io.Reader, contentType string) (*ObjectMetadata, error)
// Download retrieves data from the backend by key.
Download(ctx context.Context, key string) (io.ReadCloser, error)
// Delete removes an object from the backend.
Delete(ctx context.Context, key string) error
// Exists checks whether an object with the given key exists.
Exists(ctx context.Context, key string) (bool, error)
// BackendName returns the backend name for logging.
BackendName() string
}
Concrete Implementors: Local and S3 #
package storage
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
// LocalBackend stores files on the local filesystem.
// Suitable for development and testing.
type LocalBackend struct {
basePath string
}
func NewLocalBackend(basePath string) (*LocalBackend, error) {
if err := os.MkdirAll(basePath, 0755); err != nil {
return nil, fmt.Errorf("failed to create base path %q: %w", basePath, err)
}
return &LocalBackend{basePath: basePath}, nil
}
func (b *LocalBackend) Upload(ctx context.Context, key string, data io.Reader, contentType string) (*ObjectMetadata, error) {
fullPath := filepath.Join(b.basePath, key)
// Make sure the parent directory exists
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return nil, fmt.Errorf("failed to create directory: %w", err)
}
file, err := os.Create(fullPath)
if err != nil {
return nil, fmt.Errorf("failed to create file %q: %w", fullPath, err)
}
defer file.Close()
size, err := io.Copy(file, data)
if err != nil {
return nil, fmt.Errorf("failed to write file: %w", err)
}
return &ObjectMetadata{
Key: key,
Size: size,
ContentType: contentType,
CreatedAt: time.Now(),
URL: "file://" + fullPath,
}, nil
}
func (b *LocalBackend) Download(ctx context.Context, key string) (io.ReadCloser, error) {
fullPath := filepath.Join(b.basePath, key)
file, err := os.Open(fullPath)
if err != nil {
return nil, fmt.Errorf("failed to open file %q: %w", fullPath, err)
}
return file, nil
}
func (b *LocalBackend) Delete(ctx context.Context, key string) error {
fullPath := filepath.Join(b.basePath, key)
if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete file %q: %w", fullPath, err)
}
return nil
}
func (b *LocalBackend) Exists(ctx context.Context, key string) (bool, error) {
fullPath := filepath.Join(b.basePath, key)
_, err := os.Stat(fullPath)
if os.IsNotExist(err) {
return false, nil
}
return err == nil, err
}
func (b *LocalBackend) BackendName() string { return "local" }
// S3Backend stores files in AWS S3.
// In a real implementation: use aws-sdk-go-v2.
type S3Backend struct {
bucketName string
region string
baseURL string
// s3Client *s3.Client // aws-sdk-go-v2 client
}
func NewS3Backend(bucketName, region string) *S3Backend {
return &S3Backend{
bucketName: bucketName,
region: region,
baseURL: fmt.Sprintf("https://%s.s3.%s.amazonaws.com", bucketName, region),
}
}
func (b *S3Backend) Upload(ctx context.Context, key string, data io.Reader, contentType string) (*ObjectMetadata, error) {
// In a real implementation:
// _, err := b.s3Client.PutObject(ctx, &s3.PutObjectInput{...})
fmt.Printf("[S3] Uploading %s to bucket %s\n", key, b.bucketName)
return &ObjectMetadata{
Key: key,
ContentType: contentType,
CreatedAt: time.Now(),
URL: fmt.Sprintf("%s/%s", b.baseURL, key),
}, nil
}
func (b *S3Backend) Download(ctx context.Context, key string) (io.ReadCloser, error) {
// In a real implementation: s3Client.GetObject(...)
fmt.Printf("[S3] Downloading %s from bucket %s\n", key, b.bucketName)
return io.NopCloser(nil), nil
}
func (b *S3Backend) Delete(ctx context.Context, key string) error {
fmt.Printf("[S3] Deleting %s from bucket %s\n", key, b.bucketName)
return nil
}
func (b *S3Backend) Exists(ctx context.Context, key string) (bool, error) {
// In a real implementation: HeadObject(...)
return true, nil
}
func (b *S3Backend) BackendName() string { return "aws-s3" }
Abstraction Interface: Document #
// Document is the Abstraction interface — it defines the high-level operations
// that can be performed on a document, regardless of the backend used.
type Document interface {
// Save stores the document in the configured backend.
Save(ctx context.Context, content []byte) (*ObjectMetadata, error)
// Load retrieves the document's content from the backend.
Load(ctx context.Context) ([]byte, error)
// Delete removes the document from the backend.
Delete(ctx context.Context) error
// DocumentType returns the document type for metadata.
DocumentType() string
}
Refined Abstractions: Invoice, Report, Contract #
Each document type implements its own domain logic — format validation, naming conventions, retention policy — while storage operations are delegated to the backend through the “bridge”.
// BaseDocument provides a base implementation that document types can embed.
// This avoids duplicating the same storage operations across all types.
type BaseDocument struct {
backend StorageBackend // BRIDGE — this is the "bridge" between abstraction and implementation
key string
ownerID string
}
func newBaseDocument(backend StorageBackend, key, ownerID string) BaseDocument {
return BaseDocument{backend: backend, key: key, ownerID: ownerID}
}
func (d *BaseDocument) load(ctx context.Context) ([]byte, error) {
reader, err := d.backend.Download(ctx, d.key)
if err != nil {
return nil, fmt.Errorf("failed to load from %s: %w", d.backend.BackendName(), err)
}
defer reader.Close()
return io.ReadAll(reader)
}
func (d *BaseDocument) delete(ctx context.Context) error {
return d.backend.Delete(ctx, d.key)
}
// InvoiceDocument handles invoice storage with its own validation and naming.
type InvoiceDocument struct {
BaseDocument
invoiceNumber string
amount float64
currency string
}
func NewInvoiceDocument(backend StorageBackend, invoiceNumber, ownerID string, amount float64, currency string) *InvoiceDocument {
// Invoice-specific naming convention
key := fmt.Sprintf("invoices/%s/%s.pdf", ownerID, invoiceNumber)
return &InvoiceDocument{
BaseDocument: newBaseDocument(backend, key, ownerID),
invoiceNumber: invoiceNumber,
amount: amount,
currency: currency,
}
}
func (d *InvoiceDocument) Save(ctx context.Context, content []byte) (*ObjectMetadata, error) {
// Invoice-specific validation before saving
if len(content) == 0 {
return nil, fmt.Errorf("invoice content cannot be empty")
}
if d.amount <= 0 {
return nil, fmt.Errorf("invoice amount must be positive, got %f", d.amount)
}
meta, err := d.backend.Upload(ctx, d.key, bytes.NewReader(content), "application/pdf")
if err != nil {
return nil, fmt.Errorf("failed to save invoice %s: %w", d.invoiceNumber, err)
}
fmt.Printf("[Invoice] Saved invoice #%s (%.2f %s) via %s\n",
d.invoiceNumber, d.amount, d.currency, d.backend.BackendName())
return meta, nil
}
func (d *InvoiceDocument) Load(ctx context.Context) ([]byte, error) {
return d.load(ctx)
}
func (d *InvoiceDocument) Delete(ctx context.Context) error {
return d.delete(ctx)
}
func (d *InvoiceDocument) DocumentType() string { return "invoice" }
// ReportDocument handles report storage — usually larger and possibly in different formats.
type ReportDocument struct {
BaseDocument
reportType string // "monthly", "quarterly", "annual"
period string // "2024-Q1", "2024-01", etc.
format string // "pdf", "xlsx", "csv"
}
func NewReportDocument(backend StorageBackend, reportType, period, format, ownerID string) *ReportDocument {
key := fmt.Sprintf("reports/%s/%s/%s-%s.%s", ownerID, reportType, reportType, period, format)
return &ReportDocument{
BaseDocument: newBaseDocument(backend, key, ownerID),
reportType: reportType,
period: period,
format: format,
}
}
func (d *ReportDocument) Save(ctx context.Context, content []byte) (*ObjectMetadata, error) {
contentType := d.resolveContentType()
meta, err := d.backend.Upload(ctx, d.key, bytes.NewReader(content), contentType)
if err != nil {
return nil, fmt.Errorf("failed to save %s report for %s: %w", d.reportType, d.period, err)
}
fmt.Printf("[Report] Saved %s report (%s) via %s\n",
d.reportType, d.period, d.backend.BackendName())
return meta, nil
}
func (d *ReportDocument) Load(ctx context.Context) ([]byte, error) {
return d.load(ctx)
}
func (d *ReportDocument) Delete(ctx context.Context) error {
return d.delete(ctx)
}
func (d *ReportDocument) DocumentType() string { return "report/" + d.reportType }
func (d *ReportDocument) resolveContentType() string {
switch d.format {
case "pdf":
return "application/pdf"
case "xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case "csv":
return "text/csv"
default:
return "application/octet-stream"
}
}
Client Code: Free from Backend Details #
// DocumentService works with the Document interface — it knows no backend at all.
type DocumentService struct {
// No backend field here — that is the Document's business, not the Service's
}
func (s *DocumentService) SaveDocument(ctx context.Context, doc Document, content []byte) error {
meta, err := doc.Save(ctx, content)
if err != nil {
return fmt.Errorf("save %s failed: %w", doc.DocumentType(), err)
}
fmt.Printf("Document saved successfully: %s (size: %d bytes)\n", meta.Key, meta.Size)
return nil
}
func (s *DocumentService) LoadDocument(ctx context.Context, doc Document) ([]byte, error) {
return doc.Load(ctx)
}
Wiring in main.go — this is where abstraction and implementation get combined:
func main() {
ctx := context.Background()
// Pick the backend based on the environment
var backend storage.StorageBackend
if os.Getenv("APP_ENV") == "production" {
backend = storage.NewS3Backend("company-docs", "ap-southeast-1")
} else {
backend, _ = storage.NewLocalBackend("./storage/dev")
}
// Combine abstraction + implementation freely
invoice := storage.NewInvoiceDocument(backend, "INV-2024-001", "user-123", 1500000, "IDR")
monthlyReport := storage.NewReportDocument(backend, "monthly", "2024-01", "pdf", "user-123")
quarterlyReport := storage.NewReportDocument(backend, "quarterly", "2024-Q1", "xlsx", "user-123")
svc := &DocumentService{}
// All of them use the same backend — swapped in one place only
_ = svc.SaveDocument(ctx, invoice, invoiceContent)
_ = svc.SaveDocument(ctx, monthlyReport, reportContent)
_ = svc.SaveDocument(ctx, quarterlyReport, xlsxContent)
}
Adding New Dimensions Without Touching the Old Ones #
The Bridge Pattern’s advantage is most felt when adding new variations. Here is an example of adding a GCS backend and a ContractDocument — both added without changing a single line of existing code.
// Add a GCS backend — the StorageBackend interface already defines the contract
type GCSBackend struct {
bucketName string
projectID string
}
func NewGCSBackend(bucketName, projectID string) *GCSBackend {
return &GCSBackend{bucketName: bucketName, projectID: projectID}
}
func (b *GCSBackend) Upload(ctx context.Context, key string, data io.Reader, contentType string) (*ObjectMetadata, error) {
// Google Cloud Storage upload implementation
fmt.Printf("[GCS] Uploading %s to bucket %s\n", key, b.bucketName)
return &ObjectMetadata{Key: key, CreatedAt: time.Now()}, nil
}
func (b *GCSBackend) Download(ctx context.Context, key string) (io.ReadCloser, error) {
return io.NopCloser(nil), nil
}
func (b *GCSBackend) Delete(ctx context.Context, key string) error { return nil }
func (b *GCSBackend) Exists(ctx context.Context, key string) (bool, error) { return true, nil }
func (b *GCSBackend) BackendName() string { return "gcs" }
// Add a ContractDocument — the Document interface already defines the contract
type ContractDocument struct {
BaseDocument
contractID string
signedBy []string
expiresAt time.Time
}
func NewContractDocument(backend StorageBackend, contractID, ownerID string, expiresAt time.Time) *ContractDocument {
key := fmt.Sprintf("contracts/%s/%s.pdf", ownerID, contractID)
return &ContractDocument{
BaseDocument: newBaseDocument(backend, key, ownerID),
contractID: contractID,
expiresAt: expiresAt,
}
}
func (d *ContractDocument) Save(ctx context.Context, content []byte) (*ObjectMetadata, error) {
if time.Now().After(d.expiresAt) {
return nil, fmt.Errorf("contract %s is already expired", d.contractID)
}
return d.backend.Upload(ctx, d.key, bytes.NewReader(content), "application/pdf")
}
func (d *ContractDocument) Load(ctx context.Context) ([]byte, error) {
return d.load(ctx)
}
func (d *ContractDocument) Delete(ctx context.Context) error {
return d.delete(ctx)
}
func (d *ContractDocument) DocumentType() string { return "contract" }
// The GCS backend and ContractDocument can be combined directly with no extra code
gcsBackend := storage.NewGCSBackend("contracts-bucket", "my-project")
contract := storage.NewContractDocument(gcsBackend, "CTR-2024-099", "user-456", expiryDate)
Bridge with Runtime Changes #
One of Bridge’s advantages over inheritance is the ability to swap the implementation at runtime. This is useful for fallback strategies or A/B testing backends.
// DynamicDocumentStore allows switching backends at runtime.
type DynamicDocumentStore struct {
primaryBackend StorageBackend
fallbackBackend StorageBackend
useFallback bool
}
func NewDynamicDocumentStore(primary, fallback StorageBackend) *DynamicDocumentStore {
return &DynamicDocumentStore{
primaryBackend: primary,
fallbackBackend: fallback,
}
}
// SwitchToFallback activates the fallback backend — for example when the primary is down.
func (s *DynamicDocumentStore) SwitchToFallback() {
s.useFallback = true
fmt.Printf("Switched from %s to fallback %s\n",
s.primaryBackend.BackendName(), s.fallbackBackend.BackendName())
}
func (s *DynamicDocumentStore) ActiveBackend() StorageBackend {
if s.useFallback {
return s.fallbackBackend
}
return s.primaryBackend
}
// NewInvoiceWithStore creates an invoice using the store's active backend.
func NewInvoiceWithStore(store *DynamicDocumentStore, invoiceNumber, ownerID string, amount float64) *InvoiceDocument {
return NewInvoiceDocument(store.ActiveBackend(), invoiceNumber, ownerID, amount, "IDR")
}
Testing the Bridge Pattern #
Testing the Bridge Pattern is easier because both hierarchies can be tested separately using mocks.
// MockStorageBackend for testing Document without real I/O
type MockStorageBackend struct {
UploadFunc func(ctx context.Context, key string, data io.Reader, contentType string) (*ObjectMetadata, error)
Downloads map[string][]byte
Deleted []string
}
func NewMockBackend() *MockStorageBackend {
return &MockStorageBackend{
Downloads: make(map[string][]byte),
}
}
func (m *MockStorageBackend) Upload(ctx context.Context, key string, data io.Reader, contentType string) (*ObjectMetadata, error) {
if m.UploadFunc != nil {
return m.UploadFunc(ctx, key, data, contentType)
}
content, _ := io.ReadAll(data)
m.Downloads[key] = content
return &ObjectMetadata{Key: key, Size: int64(len(content)), CreatedAt: time.Now()}, nil
}
func (m *MockStorageBackend) Download(ctx context.Context, key string) (io.ReadCloser, error) {
content, ok := m.Downloads[key]
if !ok {
return nil, fmt.Errorf("key %q not found in mock", key)
}
return io.NopCloser(bytes.NewReader(content)), nil
}
func (m *MockStorageBackend) Delete(ctx context.Context, key string) error {
m.Deleted = append(m.Deleted, key)
delete(m.Downloads, key)
return nil
}
func (m *MockStorageBackend) Exists(ctx context.Context, key string) (bool, error) {
_, ok := m.Downloads[key]
return ok, nil
}
func (m *MockStorageBackend) BackendName() string { return "mock" }
func TestInvoiceDocument_Save_Success(t *testing.T) {
mock := NewMockBackend()
invoice := NewInvoiceDocument(mock, "INV-001", "user-123", 500000, "IDR")
content := []byte("PDF content here")
meta, err := invoice.Save(context.Background(), content)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if meta == nil {
t.Fatal("expected metadata, got nil")
}
// Verify the key naming convention
expectedKey := "invoices/user-123/INV-001.pdf"
if meta.Key != expectedKey {
t.Errorf("expected key %q, got %q", expectedKey, meta.Key)
}
// Verify the content was stored in the backend
if _, exists := mock.Downloads[expectedKey]; !exists {
t.Error("expected content to be stored in backend")
}
}
func TestInvoiceDocument_Save_InvalidAmount(t *testing.T) {
mock := NewMockBackend()
invoice := NewInvoiceDocument(mock, "INV-002", "user-123", -100, "IDR")
_, err := invoice.Save(context.Background(), []byte("content"))
if err == nil {
t.Error("expected error for negative amount, got nil")
}
// Verify no upload happened to the backend
if len(mock.Downloads) != 0 {
t.Error("expected no upload to backend when validation fails")
}
}
func TestReportDocument_DifferentBackends(t *testing.T) {
// Test that a Report can use different backends
backends := []StorageBackend{
NewMockBackend(),
NewMockBackend(),
}
for i, backend := range backends {
report := NewReportDocument(backend, "monthly", "2024-01", "pdf", "user-123")
_, err := report.Save(context.Background(), []byte("report content"))
if err != nil {
t.Errorf("backend %d: unexpected error: %v", i, err)
}
}
}
Bridge vs Other Similar Patterns #
Bridge is often confused with Strategy and Adapter because all three use composition and interfaces. The fundamental difference lies in purpose and design time.
| Aspect | Bridge | Strategy | Adapter |
|---|---|---|---|
| Purpose | Separate two hierarchies that evolve together | Swap algorithms at runtime | Adapt an incompatible interface |
| Design time | Planned from the start | Can be added any time | Usually reactive — an incompatibility needs resolving |
| Abstraction-implementation relationship | Two parallel hierarchies | One object with many strategies | One wrapper for one adaptee |
| Focus | Structure and hierarchy organization | Behavior and algorithms | Interface compatibility |
// Bridge: two hierarchies, designed together from the start
type Document interface { Save(ctx, content) error } // Abstraction hierarchy
type StorageBackend interface { Upload(ctx, key...) error } // Implementation hierarchy
// Document and StorageBackend evolve independently
// Strategy: one object with swappable strategies
type OrderProcessor struct {
pricingStrategy PricingStrategy // strategy can change at any time
}
// Only one hierarchy (OrderProcessor); Strategy is pluggable behavior
// Adapter: a reactive wrapper for an existing interface
type LegacyGateway struct { MakePayment(total int) error }
type PaymentProcessor interface { Pay(amount int, currency string) error }
// LegacyAdapter makes LegacyGateway look like PaymentProcessor
When to Use and When Not to #
USE Bridge if:
✓ There are two independent dimensions of change (type × platform, format × backend)
✓ You can foresee many combinations that keep growing
✓ You want to avoid subclass explosion from the initial design
✓ Abstraction and implementation need to be deployed or tested independently
✓ You want to be able to swap the implementation at runtime
AVOID Bridge if:
✗ There is only one axis of change — use plain inheritance or Strategy
✗ The system is still in the exploration phase and it is unclear where it will grow
✗ The possible combinations are very limited (2-3) — over-engineering
✗ The team is not familiar with this pattern — layered abstraction can be confusing
Don’t Use Bridge Too Early
The Bridge Pattern is an investment in the future — it adds structural complexity now in exchange for easier extensibility later. If you are not sure both dimensions will evolve independently, start with a direct implementation. When subclasses start multiplying for every new combination, that is the right signal to refactor into a Bridge.
Bridge Review Checklist #
DESIGN:
□ Two independent axes of change are clearly identified
□ The Abstraction interface defines high-level operations (not technical details)
□ The Implementor interface defines cohesive low-level operations
□ Refined abstractions contain only domain logic, not backend logic
IMPLEMENTATION:
□ The bridge (implementor field) is injected through the constructor, not hardcoded
□ The Abstraction delegates backend operations to the implementor, never accessing it directly
□ A BaseDocument or helper struct avoids code duplication across refined abstractions
□ Implementor errors are wrapped with informative domain context
EXTENSIBILITY:
□ Adding a new backend requires only one new struct implementing the Implementor
□ Adding a new document type requires only one new struct implementing the Abstraction
□ No existing component needs to change
TESTING:
□ The Implementor is mocked for isolated Abstraction tests
□ Tests cover domain-specific validation in every Refined Abstraction
□ Tests cover the interaction with the implementor (parameters passed are correct)
Summary #
- Bridge separates two independently evolving hierarchies — the abstraction (what is done) and the implementation (how it is done) are no longer tied to each other through inheritance.
- The key to detecting a Bridge need: two independent “axes” of change — document type × storage backend, notification type × delivery channel, shape × renderer, and so on.
- The class count grows linearly, not exponentially — adding 1 new backend = 1 new class; adding 1 new document type = 1 new class; not 1 × everything that already exists.
- Bridge is a field, not a parent class — the implementor is injected into the abstraction through the constructor; this is the most idiomatic composition-over-inheritance in Go.
- Runtime swapping becomes easy — because the implementation is a replaceable field, fallback strategies, A/B backend testing, or dynamic switching can be done without changing the abstraction at all.
- Testing becomes more granular — abstraction and implementor can be tested separately; a mock implementor enables abstraction tests isolated from I/O.
- Distinguish it from Strategy: Bridge is planned from the start for two parallel hierarchies; Strategy is added to swap behavior within one hierarchy.
- Don’t use it too early — Bridge adds structural complexity; use it when subclass explosion is threatened or has already started.