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.

← Sebelumnya: Adapter   Berikutnya: Composite →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact