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 memanggilnext.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 Pattern —
func(http.Handler) http.Handleradalah functional decorator yang kamu sudah gunakan setiap hari.- State internal harus thread-safe — decorator sering dipanggil dari multiple goroutine; gunakan
sync.Mutexatauatomicuntuk 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.