Bridge Pattern in Go: Composition-Based Bridge
The Bridge pattern solves a specific problem: what happens when you have two independent dimensions of variation in your system? Without proper structure, you end up with a cartesian product of...
Key Insights
- The Bridge pattern separates abstraction from implementation using composition, preventing the combinatorial explosion of types when two independent dimensions of variation exist.
- Go’s implicit interfaces and struct embedding make the Bridge pattern feel natural—no abstract classes or complex inheritance hierarchies required.
- Use Bridge when you have orthogonal concerns that change independently; avoid it when you only have one dimension of variation (Strategy pattern) or when adapting incompatible interfaces (Adapter pattern).
Introduction to the Bridge Pattern
The Bridge pattern solves a specific problem: what happens when you have two independent dimensions of variation in your system? Without proper structure, you end up with a cartesian product of types—every combination of abstraction and implementation becomes its own struct.
In Go, the Bridge pattern leverages composition and interfaces to decouple “what something does” from “how it does it.” The abstraction defines the high-level operations, while the implementor handles the low-level mechanics. Both can evolve independently without touching each other’s code.
This matters in large-scale Go applications because it enforces clean boundaries. When your notification system needs to support a new channel, you shouldn’t have to modify every notification type. When you add a new notification type, you shouldn’t have to implement it for every channel.
The Problem: Tight Coupling Explosion
Consider a notification system. You have different notification types (alerts, reports, reminders) and different delivery channels (email, SMS, Slack, push notifications). The naive approach creates a struct for every combination:
// The explosion begins...
type EmailAlert struct{}
type SMSAlert struct{}
type SlackAlert struct{}
type PushAlert struct{}
type EmailReport struct{}
type SMSReport struct{}
type SlackReport struct{}
type PushReport struct{}
type EmailReminder struct{}
type SMSReminder struct{}
type SlackReminder struct{}
type PushReminder struct{}
// 3 notification types × 4 channels = 12 structs
// Add one more channel? 3 more structs.
// Add one more notification type? 4 more structs.
Each struct duplicates logic. The alert formatting logic gets copy-pasted across EmailAlert, SMSAlert, SlackAlert, and PushAlert. The email sending logic gets duplicated across EmailAlert, EmailReport, and EmailReminder.
This violates DRY and makes the codebase brittle. A bug in alert formatting requires fixes in four places. A change to the email API requires updates in three places. The maintenance burden grows quadratically.
Bridge Pattern Structure in Go
The Bridge pattern splits this into two independent hierarchies connected by composition:
Abstraction side (the “what”):
- Abstraction: defines the high-level interface
- Refined Abstractions: specific variations of the abstraction
Implementor side (the “how”):
- Implementor: interface defining low-level operations
- Concrete Implementors: specific implementations
// Implementor interface - the "how" side
type MessageSender interface {
Send(recipient string, subject string, body string) error
MaxBodyLength() int
}
// Abstraction - the "what" side
type Message struct {
sender MessageSender
recipient string
}
func (m *Message) SetRecipient(recipient string) {
m.recipient = recipient
}
func (m *Message) GetSender() MessageSender {
return m.sender
}
Go’s implicit interfaces shine here. Any type implementing Send and MaxBodyLength automatically satisfies MessageSender. No explicit declarations, no inheritance trees—just behavior contracts.
Composition Over Inheritance: The Go Way
Classical Bridge implementations in languages like Java use abstract classes with inheritance. Go replaces this with struct embedding and interface composition.
The abstraction holds a reference to the implementor interface, injected at construction time:
// Base abstraction with embedded implementor
type Message struct {
sender MessageSender
recipient string
}
// Constructor with dependency injection
func NewMessage(sender MessageSender, recipient string) *Message {
return &Message{
sender: sender,
recipient: recipient,
}
}
// Refined abstraction embeds the base
type AlertMessage struct {
*Message
severity string
code string
}
func NewAlertMessage(sender MessageSender, recipient string, severity string, code string) *AlertMessage {
return &AlertMessage{
Message: NewMessage(sender, recipient),
severity: severity,
code: code,
}
}
func (a *AlertMessage) Send() error {
subject := fmt.Sprintf("[%s] Alert %s", strings.ToUpper(a.severity), a.code)
body := a.formatBody()
return a.sender.Send(a.recipient, subject, body)
}
func (a *AlertMessage) formatBody() string {
body := fmt.Sprintf("Alert Code: %s\nSeverity: %s\n", a.code, a.severity)
maxLen := a.sender.MaxBodyLength()
if len(body) > maxLen {
return body[:maxLen-3] + "..."
}
return body
}
The AlertMessage doesn’t know or care whether it’s sending via email, SMS, or carrier pigeon. It delegates to whatever MessageSender was injected. This is composition-based bridging.
Complete Implementation: Messaging System
Let’s build a complete messaging system with multiple message types and multiple senders:
package messaging
import (
"fmt"
"strings"
)
// Implementor interface
type MessageSender interface {
Send(recipient string, subject string, body string) error
MaxBodyLength() int
Name() string
}
// Concrete Implementor: Email
type EmailSender struct {
smtpHost string
smtpPort int
}
func NewEmailSender(host string, port int) *EmailSender {
return &EmailSender{smtpHost: host, smtpPort: port}
}
func (e *EmailSender) Send(recipient, subject, body string) error {
// In production: actual SMTP logic
fmt.Printf("EMAIL to %s\nSubject: %s\nBody: %s\n\n", recipient, subject, body)
return nil
}
func (e *EmailSender) MaxBodyLength() int { return 10000 }
func (e *EmailSender) Name() string { return "email" }
// Concrete Implementor: SMS
type SMSSender struct {
apiKey string
apiSecret string
}
func NewSMSSender(key, secret string) *SMSSender {
return &SMSSender{apiKey: key, apiSecret: secret}
}
func (s *SMSSender) Send(recipient, subject, body string) error {
// SMS combines subject and body, truncated
message := fmt.Sprintf("%s: %s", subject, body)
fmt.Printf("SMS to %s: %s\n\n", recipient, message)
return nil
}
func (s *SMSSender) MaxBodyLength() int { return 160 }
func (s *SMSSender) Name() string { return "sms" }
// Concrete Implementor: Slack
type SlackSender struct {
webhookURL string
channel string
}
func NewSlackSender(webhookURL, channel string) *SlackSender {
return &SlackSender{webhookURL: webhookURL, channel: channel}
}
func (s *SlackSender) Send(recipient, subject, body string) error {
// In production: HTTP POST to webhook
fmt.Printf("SLACK #%s @%s\n*%s*\n%s\n\n", s.channel, recipient, subject, body)
return nil
}
func (s *SlackSender) MaxBodyLength() int { return 4000 }
func (s *SlackSender) Name() string { return "slack" }
// Base Abstraction
type Message struct {
sender MessageSender
recipient string
}
func (m *Message) truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
// Refined Abstraction: Alert
type AlertMessage struct {
*Message
Severity string
Code string
Details string
}
func NewAlert(sender MessageSender, recipient, severity, code, details string) *AlertMessage {
return &AlertMessage{
Message: &Message{sender: sender, recipient: recipient},
Severity: severity,
Code: code,
Details: details,
}
}
func (a *AlertMessage) Send() error {
subject := fmt.Sprintf("[%s] Alert %s", strings.ToUpper(a.Severity), a.Code)
body := fmt.Sprintf("Code: %s\nSeverity: %s\nDetails: %s",
a.Code, a.Severity, a.Details)
body = a.truncate(body, a.sender.MaxBodyLength())
return a.sender.Send(a.recipient, subject, body)
}
// Refined Abstraction: Report
type ReportMessage struct {
*Message
Title string
Period string
Sections []ReportSection
}
type ReportSection struct {
Name string
Value string
}
func NewReport(sender MessageSender, recipient, title, period string) *ReportMessage {
return &ReportMessage{
Message: &Message{sender: sender, recipient: recipient},
Title: title,
Period: period,
Sections: make([]ReportSection, 0),
}
}
func (r *ReportMessage) AddSection(name, value string) {
r.Sections = append(r.Sections, ReportSection{Name: name, Value: value})
}
func (r *ReportMessage) Send() error {
subject := fmt.Sprintf("Report: %s (%s)", r.Title, r.Period)
var builder strings.Builder
builder.WriteString(fmt.Sprintf("Report: %s\nPeriod: %s\n\n", r.Title, r.Period))
for _, section := range r.Sections {
builder.WriteString(fmt.Sprintf("## %s\n%s\n\n", section.Name, section.Value))
}
body := r.truncate(builder.String(), r.sender.MaxBodyLength())
return r.sender.Send(r.recipient, subject, body)
}
Usage demonstrates the decoupling:
func main() {
// Create senders (implementors)
email := NewEmailSender("smtp.example.com", 587)
sms := NewSMSSender("key", "secret")
slack := NewSlackSender("https://hooks.slack.com/...", "alerts")
// Same alert, different channels
alert1 := NewAlert(email, "admin@example.com", "critical", "DB001", "Database connection pool exhausted")
alert2 := NewAlert(sms, "+1234567890", "critical", "DB001", "Database connection pool exhausted")
alert3 := NewAlert(slack, "oncall-team", "critical", "DB001", "Database connection pool exhausted")
alert1.Send()
alert2.Send()
alert3.Send()
// Same channel, different message types
report := NewReport(email, "cfo@example.com", "Monthly Revenue", "March 2024")
report.AddSection("Total Revenue", "$1,234,567")
report.AddSection("Growth", "+15% MoM")
report.Send()
}
Testing and Extending the Bridge
The Bridge pattern makes testing straightforward. Create a mock implementor:
type MockSender struct {
SentMessages []SentMessage
ShouldFail bool
}
type SentMessage struct {
Recipient string
Subject string
Body string
}
func (m *MockSender) Send(recipient, subject, body string) error {
if m.ShouldFail {
return fmt.Errorf("mock send failure")
}
m.SentMessages = append(m.SentMessages, SentMessage{recipient, subject, body})
return nil
}
func (m *MockSender) MaxBodyLength() int { return 500 }
func (m *MockSender) Name() string { return "mock" }
func TestAlertMessage(t *testing.T) {
mock := &MockSender{}
alert := NewAlert(mock, "test@example.com", "warning", "TEST001", "Test details")
err := alert.Send()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(mock.SentMessages) != 1 {
t.Fatalf("expected 1 message, got %d", len(mock.SentMessages))
}
if !strings.Contains(mock.SentMessages[0].Subject, "WARNING") {
t.Errorf("subject should contain severity: %s", mock.SentMessages[0].Subject)
}
}
Adding a new sender requires zero changes to existing message types:
// New implementor: Push notifications
type PushSender struct {
appID string
}
func (p *PushSender) Send(recipient, subject, body string) error {
fmt.Printf("PUSH to device %s: %s\n", recipient, subject)
return nil
}
func (p *PushSender) MaxBodyLength() int { return 256 }
func (p *PushSender) Name() string { return "push" }
// Works immediately with all existing message types
alert := NewAlert(&PushSender{appID: "myapp"}, "device-token-123", "info", "NEW001", "New feature available")
When to Use (and Avoid) the Bridge Pattern
Use Bridge when:
- You have two independent dimensions of variation
- You want to avoid a combinatorial explosion of types
- Both abstraction and implementation need to evolve independently
- You need to switch implementations at runtime
Avoid Bridge when:
- You only have one dimension of variation (use Strategy instead)
- You’re adapting incompatible interfaces (use Adapter instead)
- The added indirection doesn’t pay for itself
- Your abstractions and implementations are stable and few
Bridge vs. Strategy: Strategy varies one algorithm behind a single interface. Bridge varies both the high-level abstraction and its low-level implementation.
Bridge vs. Adapter: Adapter makes incompatible interfaces work together. Bridge separates interface from implementation by design, from the start.
The Bridge pattern adds indirection. That’s a cost. In a simple system with two message types and two senders, the pattern might be overkill. But when you’re looking at five message types, six channels, and growth in both dimensions, the Bridge pays dividends in maintainability and testability.