Decorator Pattern #

Sebuah UserRepository yang berjalan dengan baik di production tiba-tiba perlu ditambahkan caching agar tidak terus-menerus memukul database untuk data yang sama. Minggu berikutnya, tim meminta logging setiap query untuk keperluan audit. Bulan berikutnya, perlu ditambahkan metrics untuk memantau latency. Pendekatan naif: tambahkan semua logika itu langsung ke UserRepository. Hasilnya: satu file yang mencampur logika bisnis dengan logika infrastruktur, semakin besar dan semakin sulit di-test. Decorator Pattern menawarkan pendekatan yang berbeda — setiap concern tambahan dibungkus sebagai lapisan terpisah yang memiliki interface yang sama dengan objek aslinya. Repository tetap bersih, caching adalah satu decorator, logging adalah decorator lain, dan ketiganya bisa dikombinasikan dalam urutan apapun tanpa satu pun dari mereka perlu tahu keberadaan yang lain.

Apa itu Decorator Pattern? #

Decorator Pattern adalah structural design pattern yang menambahkan behavior baru ke sebuah objek secara dinamis, dengan cara membungkusnya dalam objek lain yang mengimplementasikan interface yang sama. Hasil akhirnya: objek asli tidak berubah, behavior tambahan hidup di lapisan terpisah, dan client tidak bisa membedakan apakah ia sedang berbicara dengan objek asli atau dengan stack decorator yang dibungkus di atasnya.

Perbedaan mendasar Decorator dari inheritance: inheritance menghasilkan class baru saat compile-time dengan behavior yang terkunci. Decorator menambahkan behavior saat runtime, bisa dikombinasikan secara bebas, dan bisa dicabut tanpa memodifikasi class apapun.

Tiga properti yang mendefinisikan Decorator:

  • Interface yang sama — decorator mengimplementasikan interface yang sama dengan objek yang dibungkusnya; client tidak tahu perbedaannya
  • Delegasi ke wrapped object — decorator tidak menggantikan behavior asli, ia menambahkan sebelum dan/atau sesudah mendelegasikan ke wrapped object
  • Composable — beberapa decorator bisa dirangkai; setiap lapisan hanya tahu tentang lapisan di bawahnya
flowchart LR
    C[Client] -->|"Query()"| MD[MetricsDecorator]
    MD -->|"Query()"| LD[LoggingDecorator]
    LD -->|"Query()"| CD[CachingDecorator]
    CD -->|"cache miss:\nQuery()"| R[UserRepository\nasli]

    subgraph "Stack Decorator — dari luar ke dalam"
        MD
        LD
        CD
    end

    R -->|result| CD
    CD -->|result + cache| LD
    LD -->|result + log| MD
    MD -->|result + metrics| C

Mengapa Inheritance Tidak Cukup #

Sebelum memahami Decorator, penting memahami masalah yang terjadi saat inheritance digunakan untuk tujuan yang sama.

Masalah: Subclass Explosion untuk Setiap Kombinasi #

// ANTI-PATTERN: satu subclass untuk setiap kombinasi behavior

type UserRepository struct{}
func (r *UserRepository) FindByID(id int) (*User, error) { /* ... */ }

// Ingin logging? Buat subclass
type LoggingUserRepository struct{ UserRepository }
func (r *LoggingUserRepository) FindByID(id int) (*User, error) {
    log.Printf("FindByID(%d)", id)
    return r.UserRepository.FindByID(id)
}

// Ingin caching? Buat subclass lain
type CachingUserRepository struct{ UserRepository }

// Ingin keduanya? Perlu subclass ketiga
type LoggingCachingUserRepository struct{ UserRepository }
// ... dan seterusnya untuk setiap kombinasi

// Ketika ada 3 fitur tambahan (logging, caching, metrics):
// 3 fitur = 2³ - 1 = 7 subclass yang harus dibuat dan dirawat

// BENAR: satu decorator per behavior, dikombinasikan secara bebas
repo := NewMetricsDecorator(
    NewLoggingDecorator(
        NewCachingDecorator(
            &UserRepository{},
            cache,
        ),
        logger,
    ),
    metrics,
)
// Tiga behavior, zero subclass tambahan

Anatomi Decorator di Golang #

Golang tidak punya inheritance, yang justru membuat Decorator sangat natural. Ada dua gaya implementasi yang masing-masing cocok untuk situasi berbeda.

flowchart TD
    subgraph "Struct-based Decorator"
        SI[UserRepository\ninterface]
        SC[ConcreteUserRepo\nimplementasi asli]
        SD1[LoggingDecorator\nstruct]
        SD2[CachingDecorator\nstruct]
        SI --> SC
        SI --> SD1
        SI --> SD2
        SD1 -->|wrap| SC
        SD2 -->|wrap| SC
    end

    subgraph "Functional Decorator (Go-style)"
        FI["type Handler func(req) resp"]
        FD1["func WithLogging\n(Handler) Handler"]
        FD2["func WithMetrics\n(Handler) Handler"]
        FI --> FD1
        FI --> FD2
    end
Gaya Kapan Digunakan Contoh
Struct-based Interface kompleks dengan banyak method Repository, Service
Functional Fungsi tunggal atau handler sederhana HTTP middleware, gRPC interceptor

Implementasi Lengkap: Repository dengan Decorator Stack #

Mari bangun UserRepository yang bisa dilapisi dengan caching, logging, dan metrics — masing-masing sebagai decorator terpisah yang bisa dikombinasikan secara bebas.

Component Interface #

package user

import "context"

// User merepresentasikan entitas pengguna dalam sistem.
type User struct {
    ID       int
    Name     string
    Email    string
    Role     string
    IsActive bool
}

// UserRepository adalah Component interface — kontrak yang diimplementasikan
// oleh repository asli dan semua decorator-nya.
type UserRepository interface {
    FindByID(ctx context.Context, id int) (*User, error)
    FindByEmail(ctx context.Context, email string) (*User, error)
    FindAll(ctx context.Context, limit, offset int) ([]*User, error)
    Save(ctx context.Context, user *User) error
    Delete(ctx context.Context, id int) error
}

Concrete Component: Repository Asli #

package user

import (
    "context"
    "database/sql"
    "fmt"
)

// PostgresUserRepository adalah implementasi asli yang berbicara ke database.
// Tidak ada logging, caching, atau metrics di sini — murni logika persistence.
type PostgresUserRepository struct {
    db *sql.DB
}

func NewPostgresUserRepository(db *sql.DB) UserRepository {
    return &PostgresUserRepository{db: db}
}

func (r *PostgresUserRepository) FindByID(ctx context.Context, id int) (*User, error) {
    var user User
    err := r.db.QueryRowContext(ctx,
        "SELECT id, name, email, role, is_active FROM users WHERE id = $1", id,
    ).Scan(&user.ID, &user.Name, &user.Email, &user.Role, &user.IsActive)

    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("user %d not found", id)
    }
    return &user, err
}

func (r *PostgresUserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
    var user User
    err := r.db.QueryRowContext(ctx,
        "SELECT id, name, email, role, is_active FROM users WHERE email = $1", email,
    ).Scan(&user.ID, &user.Name, &user.Email, &user.Role, &user.IsActive)

    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("user with email %q not found", email)
    }
    return &user, err
}

func (r *PostgresUserRepository) FindAll(ctx context.Context, limit, offset int) ([]*User, error) {
    rows, err := r.db.QueryContext(ctx,
        "SELECT id, name, email, role, is_active FROM users LIMIT $1 OFFSET $2", limit, offset,
    )
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var users []*User
    for rows.Next() {
        var u User
        if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Role, &u.IsActive); err != nil {
            return nil, err
        }
        users = append(users, &u)
    }
    return users, rows.Err()
}

func (r *PostgresUserRepository) Save(ctx context.Context, user *User) error {
    _, err := r.db.ExecContext(ctx,
        "INSERT INTO users (name, email, role, is_active) VALUES ($1, $2, $3, $4) ON CONFLICT (email) DO UPDATE SET name=$1, role=$3",
        user.Name, user.Email, user.Role, user.IsActive,
    )
    return err
}

func (r *PostgresUserRepository) Delete(ctx context.Context, id int) error {
    _, err := r.db.ExecContext(ctx, "DELETE FROM users WHERE id = $1", id)
    return err
}

Decorator 1: Logging #

package user

import (
    "context"
    "log/slog"
    "time"
)

// LoggingDecorator menambahkan structured logging ke setiap operasi repository.
// Tidak mengubah satu baris kode di PostgresUserRepository.
type LoggingDecorator struct {
    next   UserRepository // wrapped object — bisa repository asli atau decorator lain
    logger *slog.Logger
}

func NewLoggingDecorator(next UserRepository, logger *slog.Logger) UserRepository {
    return &LoggingDecorator{next: next, logger: logger}
}

func (d *LoggingDecorator) FindByID(ctx context.Context, id int) (*User, error) {
    start := time.Now()
    d.logger.InfoContext(ctx, "FindByID called", "user_id", id)

    user, err := d.next.FindByID(ctx, id) // delegasi ke wrapped object

    if err != nil {
        d.logger.ErrorContext(ctx, "FindByID failed",
            "user_id", id,
            "error", err,
            "duration_ms", time.Since(start).Milliseconds(),
        )
    } else {
        d.logger.InfoContext(ctx, "FindByID success",
            "user_id", id,
            "duration_ms", time.Since(start).Milliseconds(),
        )
    }
    return user, err
}

func (d *LoggingDecorator) FindByEmail(ctx context.Context, email string) (*User, error) {
    start := time.Now()
    d.logger.InfoContext(ctx, "FindByEmail called", "email", email)

    user, err := d.next.FindByEmail(ctx, email)

    fields := []any{"email", email, "duration_ms", time.Since(start).Milliseconds()}
    if err != nil {
        d.logger.ErrorContext(ctx, "FindByEmail failed", append(fields, "error", err)...)
    } else {
        d.logger.InfoContext(ctx, "FindByEmail success", fields...)
    }
    return user, err
}

func (d *LoggingDecorator) FindAll(ctx context.Context, limit, offset int) ([]*User, error) {
    start := time.Now()
    users, err := d.next.FindAll(ctx, limit, offset)

    if err != nil {
        d.logger.ErrorContext(ctx, "FindAll failed", "error", err)
    } else {
        d.logger.InfoContext(ctx, "FindAll success",
            "count", len(users),
            "limit", limit,
            "offset", offset,
            "duration_ms", time.Since(start).Milliseconds(),
        )
    }
    return users, err
}

func (d *LoggingDecorator) Save(ctx context.Context, user *User) error {
    d.logger.InfoContext(ctx, "Save called", "email", user.Email)
    err := d.next.Save(ctx, user)
    if err != nil {
        d.logger.ErrorContext(ctx, "Save failed", "email", user.Email, "error", err)
    }
    return err
}

func (d *LoggingDecorator) Delete(ctx context.Context, id int) error {
    d.logger.InfoContext(ctx, "Delete called", "user_id", id)
    err := d.next.Delete(ctx, id)
    if err != nil {
        d.logger.ErrorContext(ctx, "Delete failed", "user_id", id, "error", err)
    }
    return err
}

Decorator 2: Caching #

package user

import (
    "context"
    "fmt"
    "sync"
    "time"
)

// CacheEntry menyimpan nilai cache beserta waktu kadaluarsanya.
type CacheEntry struct {
    user      *User
    expiresAt time.Time
}

func (e *CacheEntry) isExpired() bool {
    return time.Now().After(e.expiresAt)
}

// CachingDecorator menambahkan in-memory caching ke FindByID dan FindByEmail.
// Write operations (Save, Delete) invalidasi cache yang relevan.
type CachingDecorator struct {
    next    UserRepository
    mu      sync.RWMutex
    byID    map[int]*CacheEntry
    byEmail map[string]*CacheEntry
    ttl     time.Duration
}

func NewCachingDecorator(next UserRepository, ttl time.Duration) UserRepository {
    return &CachingDecorator{
        next:    next,
        byID:    make(map[int]*CacheEntry),
        byEmail: make(map[string]*CacheEntry),
        ttl:     ttl,
    }
}

func (d *CachingDecorator) FindByID(ctx context.Context, id int) (*User, error) {
    // Cek cache terlebih dahulu
    d.mu.RLock()
    if entry, ok := d.byID[id]; ok && !entry.isExpired() {
        d.mu.RUnlock()
        return entry.user, nil // cache hit
    }
    d.mu.RUnlock()

    // Cache miss — ambil dari wrapped repository
    user, err := d.next.FindByID(ctx, id)
    if err != nil {
        return nil, err
    }

    // Simpan ke cache
    d.mu.Lock()
    d.byID[id] = &CacheEntry{user: user, expiresAt: time.Now().Add(d.ttl)}
    d.byEmail[user.Email] = &CacheEntry{user: user, expiresAt: time.Now().Add(d.ttl)}
    d.mu.Unlock()

    return user, nil
}

func (d *CachingDecorator) FindByEmail(ctx context.Context, email string) (*User, error) {
    d.mu.RLock()
    if entry, ok := d.byEmail[email]; ok && !entry.isExpired() {
        d.mu.RUnlock()
        return entry.user, nil
    }
    d.mu.RUnlock()

    user, err := d.next.FindByEmail(ctx, email)
    if err != nil {
        return nil, err
    }

    d.mu.Lock()
    d.byID[user.ID] = &CacheEntry{user: user, expiresAt: time.Now().Add(d.ttl)}
    d.byEmail[email] = &CacheEntry{user: user, expiresAt: time.Now().Add(d.ttl)}
    d.mu.Unlock()

    return user, nil
}

// FindAll tidak di-cache karena hasilnya bergantung pada limit/offset
func (d *CachingDecorator) FindAll(ctx context.Context, limit, offset int) ([]*User, error) {
    return d.next.FindAll(ctx, limit, offset)
}

// Save invalidasi cache untuk user yang diubah
func (d *CachingDecorator) Save(ctx context.Context, user *User) error {
    err := d.next.Save(ctx, user)
    if err == nil {
        d.mu.Lock()
        delete(d.byID, user.ID)
        delete(d.byEmail, user.Email)
        d.mu.Unlock()
    }
    return err
}

// Delete invalidasi cache untuk user yang dihapus
func (d *CachingDecorator) Delete(ctx context.Context, id int) error {
    // Ambil user dulu untuk mendapatkan email (agar bisa invalidasi byEmail)
    if user, err := d.FindByID(ctx, id); err == nil {
        d.mu.Lock()
        delete(d.byID, id)
        delete(d.byEmail, user.Email)
        d.mu.Unlock()
    }
    return d.next.Delete(ctx, id)
}

Decorator 3: Metrics #

package user

import (
    "context"
    "fmt"
    "time"
)

// MetricsCollector adalah interface minimal untuk recording metrics.
// Bisa diimplementasikan oleh Prometheus, Datadog, atau mock untuk testing.
type MetricsCollector interface {
    RecordDuration(operation string, duration time.Duration, err error)
    IncrementCounter(name string, tags map[string]string)
}

// MetricsDecorator mencatat durasi dan status setiap operasi repository.
type MetricsDecorator struct {
    next    UserRepository
    metrics MetricsCollector
}

func NewMetricsDecorator(next UserRepository, metrics MetricsCollector) UserRepository {
    return &MetricsDecorator{next: next, metrics: metrics}
}

func (d *MetricsDecorator) FindByID(ctx context.Context, id int) (*User, error) {
    start := time.Now()
    user, err := d.next.FindByID(ctx, id)
    d.metrics.RecordDuration("user_repo.find_by_id", time.Since(start), err)
    return user, err
}

func (d *MetricsDecorator) FindByEmail(ctx context.Context, email string) (*User, error) {
    start := time.Now()
    user, err := d.next.FindByEmail(ctx, email)
    d.metrics.RecordDuration("user_repo.find_by_email", time.Since(start), err)
    return user, err
}

func (d *MetricsDecorator) FindAll(ctx context.Context, limit, offset int) ([]*User, error) {
    start := time.Now()
    users, err := d.next.FindAll(ctx, limit, offset)
    d.metrics.RecordDuration("user_repo.find_all", time.Since(start), err)
    return users, err
}

func (d *MetricsDecorator) Save(ctx context.Context, user *User) error {
    start := time.Now()
    err := d.next.Save(ctx, user)
    d.metrics.RecordDuration("user_repo.save", time.Since(start), err)
    return err
}

func (d *MetricsDecorator) Delete(ctx context.Context, id int) error {
    start := time.Now()
    err := d.next.Delete(ctx, id)
    d.metrics.RecordDuration("user_repo.delete", time.Since(start), err)
    return err
}

Perakitan: Kombinasi Bebas #

func initUserRepository(db *sql.DB, logger *slog.Logger, metrics MetricsCollector) UserRepository {
    // Lapisan paling dalam: implementasi asli
    repo := NewPostgresUserRepository(db)

    // Lapisan caching — langsung di atas database
    repo = NewCachingDecorator(repo, 5*time.Minute)

    // Lapisan logging — di atas caching (log akan menunjukkan cache hit/miss)
    repo = NewLoggingDecorator(repo, logger)

    // Lapisan metrics — paling luar (mengukur total durasi termasuk logging)
    repo = NewMetricsDecorator(repo, metrics)

    return repo
}

// Penggunaan — client tidak tahu decorator apapun
func main() {
    repo := initUserRepository(db, logger, metricsClient)

    // Memanggil FindByID akan: record metrics → log → check cache → (jika miss) query DB
    user, err := repo.FindByID(ctx, 42)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Found: %s (%s)\n", user.Name, user.Email)
}

Urutan Decorator Itu Penting #

Urutan pembungkusan menentukan urutan eksekusi. Ini bukan detail implementasi — ini adalah keputusan desain yang punya konsekuensi nyata.

sequenceDiagram
    participant C as Client
    participant M as MetricsDecorator
    participant L as LoggingDecorator
    participant Ca as CachingDecorator
    participant DB as PostgresRepo

    C->>M: FindByID(42)
    Note over M: start timer
    M->>L: FindByID(42)
    Note over L: "FindByID called"
    L->>Ca: FindByID(42)
    Ca->>Ca: check cache
    alt cache hit
        Ca-->>L: user (cached)
    else cache miss
        Ca->>DB: FindByID(42)
        DB-->>Ca: user from DB
        Ca->>Ca: store in cache
        Ca-->>L: user
    end
    Note over L: "FindByID success (Xms)"
    L-->>M: user
    Note over M: record duration
    M-->>C: user

Perbandingan dua urutan yang berbeda:

// Urutan A: Metrics → Logging → Caching → DB
// Metrics mengukur TOTAL waktu termasuk overhead logging
// Log menunjukkan apakah ini cache hit atau cache miss
repoA := NewMetricsDecorator(
    NewLoggingDecorator(
        NewCachingDecorator(baseRepo, 5*time.Minute),
        logger,
    ),
    metrics,
)

// Urutan B: Logging → Metrics → Caching → DB
// Log tidak menunjukkan durasi yang sama dengan yang direcord Metrics
// (karena Metrics diukur di tengah, bukan dari client)
repoB := NewLoggingDecorator(
    NewMetricsDecorator(
        NewCachingDecorator(baseRepo, 5*time.Minute),
        metrics,
    ),
    logger,
)

Panduan urutan yang direkomendasikan dari luar ke dalam: Metrics → Logging → Retry → Caching → Repository asli.


Functional Decorator: Gaya Idiomatic Golang #

Untuk handler atau fungsi tunggal, Golang memiliki cara yang lebih ringkas: functional decorator. Ini adalah pola yang sama dengan middleware HTTP.

// ProcessFunc adalah tipe fungsi untuk memproses request.
type ProcessFunc func(ctx context.Context, req Request) (Response, error)

// WithLogging membungkus ProcessFunc dengan logging.
func WithLogging(logger *slog.Logger, next ProcessFunc) ProcessFunc {
    return func(ctx context.Context, req Request) (Response, error) {
        logger.InfoContext(ctx, "processing request", "request_id", req.ID)
        resp, err := next(ctx, req)
        if err != nil {
            logger.ErrorContext(ctx, "request failed", "request_id", req.ID, "error", err)
        }
        return resp, err
    }
}

// WithRetry membungkus ProcessFunc dengan logika retry.
func WithRetry(maxRetries int, next ProcessFunc) ProcessFunc {
    return func(ctx context.Context, req Request) (Response, error) {
        var (
            resp Response
            err  error
        )
        for attempt := 0; attempt <= maxRetries; attempt++ {
            resp, err = next(ctx, req)
            if err == nil {
                return resp, nil
            }
            if attempt < maxRetries {
                time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
            }
        }
        return resp, fmt.Errorf("failed after %d retries: %w", maxRetries, err)
    }
}

// WithTimeout membungkus ProcessFunc dengan timeout.
func WithTimeout(timeout time.Duration, next ProcessFunc) ProcessFunc {
    return func(ctx context.Context, req Request) (Response, error) {
        ctx, cancel := context.WithTimeout(ctx, timeout)
        defer cancel()
        return next(ctx, req)
    }
}

// Perakitan: functional decorator bisa dikombinasikan sama seperti struct-based
processor := WithTimeout(
    30*time.Second,
    WithRetry(3,
        WithLogging(logger,
            actualProcessor,
        ),
    ),
)

Ini persis bagaimana HTTP middleware di Golang bekerja:

// HTTP middleware adalah functional decorator untuk http.Handler
func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        log.Printf("→ %s %s", r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
        log.Printf("← %s %s (%v)", r.Method, r.URL.Path, time.Since(start))
    })
}

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if !isValidToken(token) {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

// Merangkai middleware — ini adalah Decorator Pattern
mux := http.NewServeMux()
mux.HandleFunc("/users", handleUsers)

handler := LoggingMiddleware(AuthMiddleware(mux))
http.ListenAndServe(":8080", handler)

Testing Decorator #

Karena setiap decorator membungkus interface, testing bisa dilakukan secara terisolasi menggunakan mock.

// MockUserRepository untuk testing decorator
type MockUserRepository struct {
    FindByIDFunc  func(ctx context.Context, id int) (*User, error)
    SaveFunc      func(ctx context.Context, user *User) error
    CallCount     map[string]int
}

func NewMockRepo() *MockUserRepository {
    return &MockUserRepository{CallCount: make(map[string]int)}
}

func (m *MockUserRepository) FindByID(ctx context.Context, id int) (*User, error) {
    m.CallCount["FindByID"]++
    if m.FindByIDFunc != nil {
        return m.FindByIDFunc(ctx, id)
    }
    return &User{ID: id, Name: "Mock User", Email: "[email protected]"}, nil
}

func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
    m.CallCount["FindByEmail"]++
    return &User{Email: email}, nil
}

func (m *MockUserRepository) FindAll(ctx context.Context, limit, offset int) ([]*User, error) {
    return nil, nil
}

func (m *MockUserRepository) Save(ctx context.Context, user *User) error {
    m.CallCount["Save"]++
    if m.SaveFunc != nil {
        return m.SaveFunc(ctx, user)
    }
    return nil
}

func (m *MockUserRepository) Delete(ctx context.Context, id int) error {
    return nil
}


func TestCachingDecorator_HitOnSecondCall(t *testing.T) {
    mock := NewMockRepo()
    cached := NewCachingDecorator(mock, 5*time.Minute)
    ctx := context.Background()

    // Panggil pertama — cache miss, harus ke mock
    user1, err := cached.FindByID(ctx, 42)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if mock.CallCount["FindByID"] != 1 {
        t.Errorf("expected 1 call to mock on first request")
    }

    // Panggil kedua — harus cache hit, mock tidak dipanggil lagi
    user2, err := cached.FindByID(ctx, 42)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if mock.CallCount["FindByID"] != 1 {
        t.Errorf("expected no additional call to mock on cache hit, got %d", mock.CallCount["FindByID"])
    }
    if user1 != user2 {
        t.Error("expected same pointer for cached result")
    }
}

func TestCachingDecorator_InvalidateOnSave(t *testing.T) {
    mock := NewMockRepo()
    cached := NewCachingDecorator(mock, 5*time.Minute)
    ctx := context.Background()

    // Populate cache
    _, _ = cached.FindByID(ctx, 42)

    // Save harus invalidasi cache
    _ = cached.Save(ctx, &User{ID: 42, Name: "Updated", Email: "[email protected]"})

    // FindByID berikutnya harus ke mock lagi (cache sudah invalidasi)
    _, _ = cached.FindByID(ctx, 42)
    if mock.CallCount["FindByID"] != 2 {
        t.Errorf("expected 2 calls to mock (before and after invalidation), got %d", mock.CallCount["FindByID"])
    }
}

func TestLoggingDecorator_LogsOnError(t *testing.T) {
    mock := NewMockRepo()
    mock.FindByIDFunc = func(ctx context.Context, id int) (*User, error) {
        return nil, fmt.Errorf("user not found")
    }

    var logBuf bytes.Buffer
    logger := slog.New(slog.NewTextHandler(&logBuf, nil))
    loggedRepo := NewLoggingDecorator(mock, logger)

    _, err := loggedRepo.FindByID(context.Background(), 99)
    if err == nil {
        t.Error("expected error to propagate")
    }
    if !strings.Contains(logBuf.String(), "FindByID failed") {
        t.Errorf("expected error to be logged, got: %s", logBuf.String())
    }
}

Hati-hati dengan Decorator yang Terlalu Dalam

Stack decorator yang terlalu banyak lapisan membuat debugging sulit — stack trace panjang dan sulit menentukan lapisan mana yang menyebabkan error. Jika stack lebih dari 4-5 lapisan, pertimbangkan apakah beberapa decorator bisa digabungkan atau apakah ini sinyal desain yang perlu ditinjau ulang.


Kesalahan Umum #

// ✗ Kesalahan 1: Decorator mengubah semantik, bukan hanya menambah behavior
func (d *CachingDecorator) Save(ctx context.Context, user *User) error {
    // Menyimpan ke cache saja, tidak memanggil next.Save()!
    d.mu.Lock()
    d.byID[user.ID] = &CacheEntry{user: user, expiresAt: time.Now().Add(d.ttl)}
    d.mu.Unlock()
    return nil // data tidak tersimpan ke database — semantik berubah total
}

// ✓ Solusi: decorator selalu mendelegasikan ke next
func (d *CachingDecorator) Save(ctx context.Context, user *User) error {
    err := d.next.Save(ctx, user) // delegasi dulu
    if err == nil {               // baru update cache
        d.mu.Lock()
        delete(d.byID, user.ID)
        d.mu.Unlock()
    }
    return err
}


// ✗ Kesalahan 2: State yang tidak thread-safe di decorator
type CountingDecorator struct {
    next  UserRepository
    count int // tidak protected oleh mutex — race condition!
}

func (d *CountingDecorator) FindByID(ctx context.Context, id int) (*User, error) {
    d.count++ // tidak aman jika dipanggil dari multiple goroutine
    return d.next.FindByID(ctx, id)
}

// ✓ Solusi: gunakan sync/atomic atau mutex untuk state yang di-share
type CountingDecorator struct {
    next  UserRepository
    count atomic.Int64
}

func (d *CountingDecorator) FindByID(ctx context.Context, id int) (*User, error) {
    d.count.Add(1) // atomic — thread-safe
    return d.next.FindByID(ctx, id)
}


// ✗ Kesalahan 3: Menaruh business logic di decorator
type AuthorizingDecorator struct {
    next UserRepository
}

func (d *AuthorizingDecorator) FindByID(ctx context.Context, id int) (*User, error) {
    user, err := d.next.FindByID(ctx, id)
    if err != nil {
        return nil, err
    }
    // Logic bisnis di decorator — salah tempat
    if user.Role == "banned" {
        return nil, errors.New("user is banned")
    }
    return user, nil
}

// ✓ Solusi: business logic ada di service/use case, bukan di decorator
// Decorator hanya untuk cross-cutting concerns: logging, caching, metrics, retry

Kapan Menggunakan dan Kapan Tidak #

GUNAKAN Decorator jika:
  ✓ Ingin menambah behavior (logging, caching, metrics, retry) tanpa mengubah kode asli
  ✓ Behavior tambahan bisa dikombinasikan dalam berbagai variasi
  ✓ Behavior tambahan adalah cross-cutting concern, bukan business logic
  ✓ Perlu menambah/mengubah behavior di runtime
  ✓ Interface sudah stabil dan tidak akan sering berubah

HINDARI Decorator jika:
  ✗ Interface sangat besar (banyak method) — setiap decorator harus implementasi semua
  ✗ Behavior yang ditambahkan adalah business logic — gunakan Service atau Use Case
  ✗ Hanya ada satu kombinasi behavior yang dipakai — overkill, modifikasi langsung saja
  ✗ Perlu akses ke state internal wrapped object — Decorator tidak bisa mengakses ini

Checklist Review Decorator #

DESAIN:
  □ Decorator mengimplementasikan interface yang sama persis dengan wrapped object
  □ Decorator selalu mendelegasikan ke next — tidak ada method yang "menelan" panggilan
  □ Hanya cross-cutting concern yang ada di decorator — bukan business logic
  □ Interface Component cukup kecil — decorator tidak kesulitan mengimplementasikan semua method

IMPLEMENTASI:
  □ State internal decorator thread-safe (mutex atau atomic jika diperlukan)
  □ Error dari wrapped object selalu diteruskan ke pemanggil
  □ Urutan decorator sudah dipertimbangkan dan didokumentasikan
  □ Tidak ada circular dependency antar decorator

TESTING:
  □ Setiap decorator di-test secara terpisah dengan mock wrapped object
  □ Cache hit dan cache miss di-test untuk CachingDecorator
  □ Invalidasi cache di-test setelah operasi write
  □ Error propagation dari wrapped object di-test
  □ Thread-safety di-test untuk decorator yang punya shared state

Ringkasan #

  • Decorator menambah behavior tanpa mengubah kode asli — wrapped object tidak tahu ada decorator di atasnya; client tidak tahu ada decorator di bawahnya.
  • Interface yang sama adalah kunci — decorator mengimplementasikan interface yang sama dengan wrapped object; inilah yang membuat stack decorator transparan bagi client.
  • Selalu delegasikan ke next — decorator tidak boleh “menelan” panggilan; ia harus memanggil next.Method() untuk setiap method, sebelum atau sesudah logikanya sendiri.
  • Urutan decorator menentukan urutan eksekusi — Metrics → Logging → Caching → DB adalah urutan yang berbeda dari Logging → Metrics → Caching → DB; pahami konsekuensinya sebelum merangkai.
  • Dua gaya di Golang: struct-based untuk interface kompleks dengan banyak method; functional decorator untuk handler atau fungsi tunggal — keduanya adalah Decorator Pattern.
  • HTTP middleware adalah Decorator Patternfunc(http.Handler) http.Handler adalah functional decorator yang kamu sudah gunakan setiap hari.
  • State internal harus thread-safe — decorator sering dipanggil dari multiple goroutine; gunakan sync.Mutex atau atomic untuk field yang di-share.
  • Hanya untuk cross-cutting concern — logging, caching, metrics, retry, circuit breaker; bukan business logic yang seharusnya ada di service atau use case.

← Sebelumnya: Composite   Berikutnya: Facade →

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