Bridge Pattern #
Misalkan kamu membangun sistem notifikasi. Ada tiga jenis notifikasi — Alert, Reminder, dan Report — dan ada empat channel pengiriman: Email, SMS, Push Notification, dan Slack. Dengan inheritance naif, kamu akan berakhir membuat dua belas class: AlertEmail, AlertSMS, AlertPush, AlertSlack, ReminderEmail, ReminderSMS, dan seterusnya. Sekarang bayangkan channel kelima ditambahkan — WhatsApp — dan kamu harus menambah tiga class lagi. Lalu tipe notifikasi keempat ditambahkan, dan tiba-tiba ada dua puluh class yang harus dirawat. Inilah yang disebut subclass explosion, dan ini adalah masalah yang Bridge Pattern dirancang untuk mencegahnya. Alih-alih menciptakan class untuk setiap kombinasi, Bridge Pattern memisahkan dua “dimensi” perubahan menjadi dua hierarki independen yang dihubungkan melalui komposisi — sehingga menambah channel baru atau tipe baru cukup dengan menambah satu class, bukan sebanyak dimensi lainnya.
Apa itu Bridge Pattern? #
Bridge Pattern adalah structural design pattern yang memisahkan abstraction dari implementation ke dalam dua hierarki kelas yang terpisah, memungkinkan keduanya berkembang secara independen. Kata kunci di sini adalah independen: perubahan di sisi abstraction tidak memaksa perubahan di sisi implementation, dan sebaliknya.
Di Golang yang tidak mendukung inheritance klasik, Bridge Pattern sangat natural karena Golang memang mendorong composition over inheritance. “Bridge” yang dimaksud adalah interface yang menghubungkan abstraction ke implementation — bukan class middle-man, melainkan kontrak sederhana yang membuat keduanya bisa dipertukarkan.
Tiga sifat yang mendefinisikan Bridge Pattern:
- Dua hierarki yang berkembang sendiri-sendiri — abstraction dan implementation bisa ditambahkan variasi tanpa saling tahu
- Dependency injection sebagai “jembatan” — implementation diinjeksikan ke abstraction saat konstruksi, bukan dikodekan secara tetap
- Kombinasi runtime — kamu bisa menggabungkan abstraction dan implementation apapun yang kompatibel, bahkan di saat aplikasi berjalan
flowchart LR
subgraph "Tanpa Bridge — Subclass Explosion"
direction TB
AE[AlertEmail]
AS[AlertSMS]
AP[AlertPush]
RE[ReminderEmail]
RS[ReminderSMS]
RP[ReminderPush]
note1["3 tipe × 3 channel\n= 9 class\n\nTambah 1 channel → +3 class\nTambah 1 tipe → +3 class"]
end
subgraph "Dengan Bridge — Independen"
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 tipe + 3 channel = 6 class\n\nTambah 1 channel → +1 class\nTambah 1 tipe → +1 class"]
end
Dua Axis Perubahan — Inti dari Bridge #
Cara termudah mendeteksi apakah Bridge Pattern dibutuhkan: tanyakan “apakah ada dua dimensi perubahan yang independen di sini?”. Jika jawabannya ya, Bridge hampir selalu menjadi kandidat kuat.
| Domain | Axis 1 (Abstraction) | Axis 2 (Implementation) |
|---|---|---|
| Notifikasi | 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 |
Dua axis dikatakan independen jika menambahkan variasi di salah satu axis tidak memerlukan perubahan di axis lain. Email bisa mengirim Alert atau Reminder tanpa perlu tahu perbedaannya — ia hanya perlu tahu apa yang harus dikirim, bukan mengapa dikirim.
Struktur dan Komponen #
Bridge Pattern di Golang melibatkan empat komponen yang bekerja bersama.
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
| Komponen | Peran | Berkembang Ke Mana |
|---|---|---|
| Abstraction interface | Kontrak untuk semua tipe notifikasi | Ditambah tipe baru (Warning, Report) |
| Refined Abstraction | Implementasi konkret abstraction | Alert, Reminder, Report, dll |
| Implementor interface | Kontrak untuk semua channel pengiriman | Ditambah channel baru (WhatsApp) |
| Concrete Implementor | Implementasi konkret channel | Email, SMS, Push, Slack, dll |
Implementasi Lengkap: Storage Service #
Studi kasus yang lebih kompleks dari sekadar notifikasi — sebuah storage service yang bisa menyimpan berbagai tipe dokumen (Invoice, Report, Contract) ke berbagai backend (local filesystem, AWS S3, Google Cloud Storage). Ini adalah Bridge Pattern yang sangat sering ditemui di aplikasi enterprise.
Implementor Interface: StorageBackend #
package storage
import (
"context"
"io"
"time"
)
// ObjectMetadata menyimpan informasi tentang objek yang tersimpan.
type ObjectMetadata struct {
Key string
Size int64
ContentType string
CreatedAt time.Time
URL string // URL publik jika backend mendukung
}
// StorageBackend adalah Implementor interface — "jembatan" antara
// document abstraction dan backend konkret.
// Setiap backend harus memenuhi kontrak ini.
type StorageBackend interface {
// Upload menyimpan data ke backend dengan key tertentu.
Upload(ctx context.Context, key string, data io.Reader, contentType string) (*ObjectMetadata, error)
// Download mengambil data dari backend berdasarkan key.
Download(ctx context.Context, key string) (io.ReadCloser, error)
// Delete menghapus objek dari backend.
Delete(ctx context.Context, key string) error
// Exists memeriksa apakah objek dengan key tertentu ada.
Exists(ctx context.Context, key string) (bool, error)
// BackendName mengembalikan nama backend untuk logging.
BackendName() string
}
Concrete Implementors: Local dan S3 #
package storage
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
// LocalBackend menyimpan file di filesystem lokal.
// Cocok untuk development dan 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)
// Pastikan direktori parent ada
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 menyimpan file di AWS S3.
// Dalam implementasi nyata: gunakan 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) {
// Dalam implementasi nyata:
// _, 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) {
// Dalam implementasi nyata: 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) {
// Dalam implementasi nyata: HeadObject(...)
return true, nil
}
func (b *S3Backend) BackendName() string { return "aws-s3" }
Abstraction Interface: Document #
// Document adalah Abstraction interface — mendefinisikan operasi tingkat tinggi
// yang bisa dilakukan pada dokumen, terlepas dari backend yang digunakan.
type Document interface {
// Save menyimpan dokumen ke backend yang dikonfigurasi.
Save(ctx context.Context, content []byte) (*ObjectMetadata, error)
// Load mengambil isi dokumen dari backend.
Load(ctx context.Context) ([]byte, error)
// Delete menghapus dokumen dari backend.
Delete(ctx context.Context) error
// DocumentType mengembalikan tipe dokumen untuk metadata.
DocumentType() string
}
Refined Abstractions: Invoice, Report, Contract #
Setiap tipe dokumen mengimplementasikan logika domain-nya sendiri — validasi format, naming convention, retention policy — sementara operasi storage didelegasikan ke backend melalui “jembatan”.
// BaseDocument menyediakan implementasi dasar yang bisa di-embed oleh tipe dokumen.
// Ini menghindari duplikasi kode untuk operasi storage yang sama di semua tipe.
type BaseDocument struct {
backend StorageBackend // BRIDGE — inilah "jembatan" antara abstraction dan 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 menangani penyimpanan invoice dengan validasi dan naming-nya sendiri.
type InvoiceDocument struct {
BaseDocument
invoiceNumber string
amount float64
currency string
}
func NewInvoiceDocument(backend StorageBackend, invoiceNumber, ownerID string, amount float64, currency string) *InvoiceDocument {
// Naming convention spesifik untuk invoice
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) {
// Validasi spesifik invoice sebelum disimpan
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 menangani penyimpanan laporan — biasanya lebih besar dan bisa berformat berbeda.
type ReportDocument struct {
BaseDocument
reportType string // "monthly", "quarterly", "annual"
period string // "2024-Q1", "2024-01", dll
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: Bebas dari Detail Backend #
// DocumentService bekerja dengan Document interface — tidak tahu backend apapun.
type DocumentService struct {
// Tidak ada field backend di sini — itu urusan Document, bukan Service
}
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)
}
Perakitan di main.go — di sinilah abstraction dan implementation digabungkan:
func main() {
ctx := context.Background()
// Pilih backend berdasarkan 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")
}
// Kombinasikan abstraction + implementation secara bebas
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{}
// Semua menggunakan backend yang sama — hanya diganti di satu tempat
_ = svc.SaveDocument(ctx, invoice, invoiceContent)
_ = svc.SaveDocument(ctx, monthlyReport, reportContent)
_ = svc.SaveDocument(ctx, quarterlyReport, xlsxContent)
}
Menambah Dimensi Baru Tanpa Mengubah yang Lama #
Keunggulan Bridge Pattern paling terasa saat menambah variasi baru. Berikut contoh menambah GCS backend dan ContractDocument — keduanya ditambahkan tanpa mengubah satu baris kode yang sudah ada.
// Tambah GCS backend — StorageBackend interface sudah mendefinisikan kontrak
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) {
// Implementasi Google Cloud Storage upload
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" }
// Tambah ContractDocument — Document interface sudah mendefinisikan kontrak
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" }
// GCS backend dan ContractDocument langsung bisa dikombinasikan tanpa kode tambahan
gcsBackend := storage.NewGCSBackend("contracts-bucket", "my-project")
contract := storage.NewContractDocument(gcsBackend, "CTR-2024-099", "user-456", expiryDate)
Bridge dengan Perubahan Runtime #
Salah satu keunggulan Bridge dibanding inheritance adalah kemampuan mengganti implementation saat runtime. Ini berguna untuk fallback strategy atau A/B testing backend.
// DynamicDocumentStore memungkinkan pergantian backend saat runtime.
type DynamicDocumentStore struct {
primaryBackend StorageBackend
fallbackBackend StorageBackend
useFallback bool
}
func NewDynamicDocumentStore(primary, fallback StorageBackend) *DynamicDocumentStore {
return &DynamicDocumentStore{
primaryBackend: primary,
fallbackBackend: fallback,
}
}
// SwitchToFallback mengaktifkan fallback backend — misalnya saat primary sedang 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 membuat invoice yang menggunakan backend aktif dari store.
func NewInvoiceWithStore(store *DynamicDocumentStore, invoiceNumber, ownerID string, amount float64) *InvoiceDocument {
return NewInvoiceDocument(store.ActiveBackend(), invoiceNumber, ownerID, amount, "IDR")
}
Testing Bridge Pattern #
Testing Bridge Pattern lebih mudah karena kedua hierarki bisa di-test secara terpisah menggunakan mock.
// MockStorageBackend untuk testing Document tanpa I/O sungguhan
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")
}
// Verifikasi key naming convention
expectedKey := "invoices/user-123/INV-001.pdf"
if meta.Key != expectedKey {
t.Errorf("expected key %q, got %q", expectedKey, meta.Key)
}
// Verifikasi konten tersimpan ke 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")
}
// Verifikasi tidak ada upload ke backend
if len(mock.Downloads) != 0 {
t.Error("expected no upload to backend when validation fails")
}
}
func TestReportDocument_DifferentBackends(t *testing.T) {
// Test bahwa Report bisa menggunakan backend yang berbeda-beda
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 Pattern Lain yang Mirip #
Bridge sering dikacaukan dengan Strategy dan Adapter karena ketiganya menggunakan komposisi dan interface. Perbedaan mendasarnya ada di tujuan dan waktu desain.
| Aspek | Bridge | Strategy | Adapter |
|---|---|---|---|
| Tujuan | Pisahkan dua hierarki yang berkembang bersama | Ganti algoritma saat runtime | Sesuaikan interface yang tidak kompatibel |
| Waktu desain | Direncanakan sejak awal | Bisa ditambahkan kapan saja | Biasanya reaktif — ada ketidakcocokan yang harus diselesaikan |
| Hubungan abstraction-implementation | Dua hierarki paralel | Satu objek punya banyak strategi | Satu wrapper untuk satu adaptee |
| Fokus | Struktur dan organisasi hierarki | Behavior dan algoritma | Kompatibilitas interface |
// Bridge: dua hierarki, dirancang bersama dari awal
type Document interface { Save(ctx, content) error } // Abstraction hierarchy
type StorageBackend interface { Upload(ctx, key...) error } // Implementation hierarchy
// Document dan StorageBackend berkembang independen
// Strategy: satu objek dengan strategi yang bisa diganti
type OrderProcessor struct {
pricingStrategy PricingStrategy // strategi bisa diubah kapan saja
}
// Hanya ada satu hierarki (OrderProcessor), Strategy adalah pluggable behavior
// Adapter: wrapper reaktif untuk interface yang sudah ada
type LegacyGateway struct { MakePayment(total int) error }
type PaymentProcessor interface { Pay(amount int, currency string) error }
// LegacyAdapter membuat LegacyGateway terlihat seperti PaymentProcessor
Kapan Menggunakan dan Kapan Tidak #
GUNAKAN Bridge jika:
✓ Ada dua dimensi perubahan yang independen (tipe × platform, format × backend)
✓ Sudah terbayang akan ada banyak kombinasi yang terus bertambah
✓ Ingin menghindari subclass explosion sejak desain awal
✓ Abstraction dan implementation perlu di-deploy atau di-test secara independen
✓ Ingin bisa mengganti implementation saat runtime
HINDARI Bridge jika:
✗ Hanya ada satu axis perubahan — gunakan inheritance atau Strategy biasa
✗ Sistem masih dalam tahap eksplorasi dan belum jelas akan berkembang ke mana
✗ Kombinasi yang mungkin terjadi sangat terbatas (2–3 saja) — over-engineering
✗ Tim belum familiar dengan pattern ini — abstraksi berlapis bisa membingungkan
Jangan Gunakan Bridge Terlalu Dini
Bridge Pattern adalah investasi untuk masa depan — ia menambah kompleksitas struktural sekarang dengan imbalan kemudahan extensibility nanti. Jika kamu belum yakin bahwa kedua dimensi akan berkembang secara independen, mulai dengan implementasi langsung. Ketika subclass mulai bermunculan untuk setiap kombinasi baru, itulah sinyal yang tepat untuk refactor ke Bridge.
Checklist Review Bridge #
DESAIN:
□ Dua axis perubahan yang independen sudah teridentifikasi dengan jelas
□ Abstraction interface mendefinisikan operasi tingkat tinggi (bukan detail teknis)
□ Implementor interface mendefinisikan operasi tingkat rendah yang cohesive
□ Refined abstraction hanya berisi logika domain, bukan logika backend
IMPLEMENTASI:
□ Bridge (field implementor) diinjeksikan melalui constructor, bukan hardcode
□ Abstraction mendelegasikan operasi backend ke implementor, tidak mengaksesnya langsung
□ BaseDocument atau struct helper menghindari duplikasi kode antar refined abstraction
□ Error dari implementor di-wrap dengan konteks domain yang informatif
EKSTENSIBILITAS:
□ Menambah backend baru hanya memerlukan satu struct baru yang mengimplementasikan Implementor
□ Menambah tipe dokumen baru hanya memerlukan satu struct baru yang mengimplementasikan Abstraction
□ Tidak ada kode yang perlu diubah di komponen yang sudah ada
TESTING:
□ Implementor di-mock untuk test Abstraction yang terisolasi
□ Test mencakup validasi domain-specific di setiap Refined Abstraction
□ Test mencakup interaksi dengan implementor (parameter yang dikirim benar)
Ringkasan #
- Bridge memisahkan dua hierarki yang berkembang independen — abstraction (apa yang dilakukan) dan implementation (bagaimana dilakukan) tidak lagi terikat satu sama lain melalui inheritance.
- Kunci mendeteksi kebutuhan Bridge: ada dua “axis” perubahan yang independen — tipe dokumen × storage backend, tipe notifikasi × channel pengiriman, bentuk × renderer, dan sebagainya.
- Jumlah class tumbuh linier, bukan eksponensial — menambah 1 backend baru = 1 class baru; menambah 1 tipe dokumen baru = 1 class baru; bukan 1 × semua yang sudah ada.
- Bridge adalah field, bukan parent class — implementor diinjeksikan ke abstraction melalui constructor; ini adalah composition over inheritance yang paling idiomatik di Golang.
- Pergantian runtime menjadi mudah — karena implementation adalah field yang bisa diganti, fallback strategy, A/B testing backend, atau dynamic switching bisa dilakukan tanpa mengubah abstraction sama sekali.
- Testing menjadi lebih granular — abstraction dan implementor bisa di-test secara terpisah; mock implementor memungkinkan test abstraction yang terisolasi dari I/O.
- Bedakan dari Strategy: Bridge direncanakan dari awal untuk dua hierarki paralel; Strategy ditambahkan untuk mengganti behavior dalam satu hierarki.
- Jangan gunakan terlalu dini — Bridge menambah kompleksitas struktural; gunakan saat subclass explosion sudah terancam atau sudah mulai terjadi.