Decorator Pattern #
A UserRepository running smoothly in production suddenly needs caching so it stops hammering the database for the same data. The next week, the team asks for logging on every query for auditing. The month after, metrics need to be added to monitor latency. The naive approach: add all that logic directly to UserRepository. The result: one file mixing business logic with infrastructure logic, growing bigger and harder to test. The Decorator Pattern offers a different approach — each extra concern is wrapped as a separate layer that implements the same interface as the original object. The repository stays clean, caching is one decorator, logging is another, and all of them can be combined in any order without any of them needing to know the others exist.
What Is the Decorator Pattern? #
The Decorator Pattern is a structural design pattern that adds new behavior to an object dynamically, by wrapping it in another object that implements the same interface. The end result: the original object is unchanged, extra behavior lives in a separate layer, and the client cannot tell whether it is talking to the original object or to a stack of decorators wrapped around it.
The fundamental difference between Decorator and inheritance: inheritance produces a new class at compile time with locked-in behavior. Decorator adds behavior at runtime, can be combined freely, and can be removed without modifying any class.
Three properties define the Decorator:
- Same interface — the decorator implements the same interface as the object it wraps; the client does not know the difference
- Delegation to the wrapped object — the decorator does not replace the original behavior; it adds before and/or after delegating to the wrapped object
- Composable — multiple decorators can be chained; each layer only knows about the layer beneath it
flowchart LR
C[Client] -->|"Query()"| MD[MetricsDecorator]
MD -->|"Query()"| LD[LoggingDecorator]
LD -->|"Query()"| CD[CachingDecorator]
CD -->|"cache miss:\\nQuery()"| R[UserRepository\\noriginal]
subgraph "Decorator Stack — outside to inside"
MD
LD
CD
end
R -->|result| CD
CD -->|result + cache| LD
LD -->|result + log| MD
MD -->|result + metrics| CWhy Inheritance Is Not Enough #
Before understanding Decorator, it is important to understand the problem that occurs when inheritance is used for the same purpose.
The Problem: Subclass Explosion for Every Combination #
// ANTI-PATTERN: one subclass for every behavior combination
type UserRepository struct{}
func (r *UserRepository) FindByID(id int) (*User, error) { /* ... */ }
// Want logging? Create a subclass
type LoggingUserRepository struct{ UserRepository }
func (r *LoggingUserRepository) FindByID(id int) (*User, error) {
log.Printf("FindByID(%d)", id)
return r.UserRepository.FindByID(id)
}
// Want caching? Create another subclass
type CachingUserRepository struct{ UserRepository }
// Want both? You need a third subclass
type LoggingCachingUserRepository struct{ UserRepository }
// ... and so on for every combination
// With 3 extra features (logging, caching, metrics):
// 3 features = 2³ - 1 = 7 subclasses that must be created and maintained
// CORRECT: one decorator per behavior, combined freely
repo := NewMetricsDecorator(
NewLoggingDecorator(
NewCachingDecorator(
&UserRepository{},
cache,
),
logger,
),
metrics,
)
// Three behaviors, zero extra subclasses
Anatomy of a Decorator in Go #
Go has no inheritance, which actually makes Decorator very natural. There are two implementation styles, each suited to different situations.
flowchart TD
subgraph "Struct-based Decorator"
SI[UserRepository\\ninterface]
SC[ConcreteUserRepo\\noriginal implementation]
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| Style | When to Use | Examples |
|---|---|---|
| Struct-based | Complex interface with many methods | Repository, Service |
| Functional | Single function or simple handler | HTTP middleware, gRPC interceptor |
Full Implementation: Repository with a Decorator Stack #
Let’s build a UserRepository that can be layered with caching, logging, and metrics — each as a separate decorator that can be combined freely.
Component Interface #
package user
import "context"
// User represents a user entity in the system.
type User struct {
ID int
Name string
Email string
Role string
IsActive bool
}
// UserRepository is the Component interface — the contract implemented
// by the original repository and all its decorators.
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: The Original Repository #
package user
import (
"context"
"database/sql"
"fmt"
)
// PostgresUserRepository is the original implementation that talks to the database.
// No logging, caching, or metrics here — pure persistence logic.
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 adds structured logging to every repository operation.
// It does not change a single line in PostgresUserRepository.
type LoggingDecorator struct {
next UserRepository // wrapped object — the original repository or another decorator
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) // delegate to the 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 stores a cached value along with its expiry time.
type CacheEntry struct {
user *User
expiresAt time.Time
}
func (e *CacheEntry) isExpired() bool {
return time.Now().After(e.expiresAt)
}
// CachingDecorator adds in-memory caching to FindByID and FindByEmail.
// Write operations (Save, Delete) invalidate the relevant cache entries.
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) {
// Check the cache first
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 — fetch from the wrapped repository
user, err := d.next.FindByID(ctx, id)
if err != nil {
return nil, err
}
// Store in the 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 is not cached because its result depends on limit/offset
func (d *CachingDecorator) FindAll(ctx context.Context, limit, offset int) ([]*User, error) {
return d.next.FindAll(ctx, limit, offset)
}
// Save invalidates the cache for the modified user
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 invalidates the cache for the deleted user
func (d *CachingDecorator) Delete(ctx context.Context, id int) error {
// Fetch the user first to get the email (so byEmail can be invalidated too)
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 is a minimal interface for recording metrics.
// It can be implemented by Prometheus, Datadog, or a mock for testing.
type MetricsCollector interface {
RecordDuration(operation string, duration time.Duration, err error)
IncrementCounter(name string, tags map[string]string)
}
// MetricsDecorator records the duration and status of every repository operation.
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
}
Assembly: Free Combination #
func initUserRepository(db *sql.DB, logger *slog.Logger, metrics MetricsCollector) UserRepository {
// Innermost layer: the original implementation
repo := NewPostgresUserRepository(db)
// Caching layer — directly on top of the database
repo = NewCachingDecorator(repo, 5*time.Minute)
// Logging layer — on top of caching (logs will show cache hit/miss)
repo = NewLoggingDecorator(repo, logger)
// Metrics layer — outermost (measures total duration including logging)
repo = NewMetricsDecorator(repo, metrics)
return repo
}
// Usage — the client knows no decorators at all
func main() {
repo := initUserRepository(db, logger, metricsClient)
// Calling FindByID will: record metrics → log → check cache → (if 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)
}
Decorator Order Matters #
The wrapping order determines the execution order. This is not an implementation detail — it is a design decision with real consequences.
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: userComparing two different orders:
// Order A: Metrics → Logging → Caching → DB
// Metrics measures the TOTAL time including logging overhead
// Logs show whether this was a cache hit or cache miss
repoA := NewMetricsDecorator(
NewLoggingDecorator(
NewCachingDecorator(baseRepo, 5*time.Minute),
logger,
),
metrics,
)
// Order B: Logging → Metrics → Caching → DB
// Logs do not show the same duration Metrics records
// (because Metrics is measured in the middle, not from the client)
repoB := NewLoggingDecorator(
NewMetricsDecorator(
NewCachingDecorator(baseRepo, 5*time.Minute),
metrics,
),
logger,
)
The recommended order from outside to inside: Metrics → Logging → Retry → Caching → Original repository.
Functional Decorator: The Idiomatic Go Style #
For handlers or single functions, Go has a more concise way: the functional decorator. This is the same pattern as HTTP middleware.
// ProcessFunc is a function type for processing requests.
type ProcessFunc func(ctx context.Context, req Request) (Response, error)
// WithLogging wraps a ProcessFunc with 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 wraps a ProcessFunc with retry logic.
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 wraps a ProcessFunc with a 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)
}
}
// Assembly: functional decorators combine just like struct-based ones
processor := WithTimeout(
30*time.Second,
WithRetry(3,
WithLogging(logger,
actualProcessor,
),
),
)
This is exactly how HTTP middleware works in Go:
// HTTP middleware is a functional decorator for 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)
})
}
// Chaining middleware — this is the Decorator Pattern
mux := http.NewServeMux()
mux.HandleFunc("/users", handleUsers)
handler := LoggingMiddleware(AuthMiddleware(mux))
http.ListenAndServe(":8080", handler)
Testing a Decorator #
Because every decorator wraps an interface, testing can be done in isolation using mocks.
// MockUserRepository for testing decorators
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()
// First call — cache miss, must hit the 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")
}
// Second call — must be a cache hit, mock is not called again
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 the cache
_, _ = cached.FindByID(ctx, 42)
// Save must invalidate the cache
_ = cached.Save(ctx, &User{ID: 42, Name: "Updated", Email: "[email protected]"})
// The next FindByID must hit the mock again (cache was invalidated)
_, _ = 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())
}
}
Beware of Decorators That Stack Too Deep
A decorator stack with too many layers makes debugging difficult — long stack traces and hard to pinpoint which layer caused the error. If the stack is more than 4-5 layers, consider whether some decorators can be merged or whether this is a sign the design needs revisiting.
Common Mistakes #
// ✗ Mistake 1: A decorator changes semantics instead of only adding behavior
func (d *CachingDecorator) Save(ctx context.Context, user *User) error {
// Only saves to the cache, never calls next.Save()!
d.mu.Lock()
d.byID[user.ID] = &CacheEntry{user: user, expiresAt: time.Now().Add(d.ttl)}
d.mu.Unlock()
return nil // data is not stored in the database — semantics completely changed
}
// ✓ Solution: a decorator always delegates to next
func (d *CachingDecorator) Save(ctx context.Context, user *User) error {
err := d.next.Save(ctx, user) // delegate first
if err == nil { // then update the cache
d.mu.Lock()
delete(d.byID, user.ID)
d.mu.Unlock()
}
return err
}
// ✗ Mistake 2: Non-thread-safe state in a decorator
type CountingDecorator struct {
next UserRepository
count int // not protected by a mutex — race condition!
}
func (d *CountingDecorator) FindByID(ctx context.Context, id int) (*User, error) {
d.count++ // unsafe if called from multiple goroutines
return d.next.FindByID(ctx, id)
}
// ✓ Solution: use sync/atomic or a mutex for shared state
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)
}
// ✗ Mistake 3: Putting business logic in a 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
}
// Business logic in a decorator — wrong place
if user.Role == "banned" {
return nil, errors.New("user is banned")
}
return user, nil
}
// ✓ Solution: business logic belongs in a service/use case, not a decorator
// Decorators are only for cross-cutting concerns: logging, caching, metrics, retry
When to Use and When Not to #
USE Decorator if:
✓ You want to add behavior (logging, caching, metrics, retry) without changing the original code
✓ Extra behaviors can be combined in various arrangements
✓ The extra behavior is a cross-cutting concern, not business logic
✓ You need to add or change behavior at runtime
✓ The interface is stable and unlikely to change often
AVOID Decorator if:
✗ The interface is very large (many methods) — every decorator must implement all of them
✗ The behavior being added is business logic — use a Service or Use Case instead
✗ Only one behavior combination is ever used — overkill, just modify directly
✗ You need access to the wrapped object's internal state — a Decorator cannot access that
Decorator Review Checklist #
DESIGN:
□ The decorator implements the exact same interface as the wrapped object
□ The decorator always delegates to next — no method "swallows" the call
□ Only cross-cutting concerns live in the decorator — not business logic
□ The Component interface is small enough — the decorator has no trouble implementing every method
IMPLEMENTATION:
□ Internal decorator state is thread-safe (mutex or atomic if needed)
□ Errors from the wrapped object are always propagated to the caller
□ The decorator order has been considered and documented
□ No circular dependencies between decorators
TESTING:
□ Every decorator is tested separately with a mocked wrapped object
□ Cache hits and cache misses are tested for the CachingDecorator
□ Cache invalidation is tested after write operations
□ Error propagation from the wrapped object is tested
□ Thread-safety is tested for decorators with shared state
Summary #
- Decorator adds behavior without changing the original code — the wrapped object does not know a decorator sits above it; the client does not know a decorator sits below it.
- The same interface is the key — the decorator implements the same interface as the wrapped object; this is what makes a decorator stack transparent to the client.
- Always delegate to
next— a decorator must never “swallow” a call; it must callnext.Method()for every method, before or after its own logic.- Decorator order determines execution order — Metrics → Logging → Caching → DB is a different order from Logging → Metrics → Caching → DB; understand the consequences before chaining.
- Two styles in Go: struct-based for complex interfaces with many methods; functional decorators for handlers or single functions — both are the Decorator Pattern.
- HTTP middleware is the Decorator Pattern —
func(http.Handler) http.Handleris a functional decorator you already use every day.- Internal state must be thread-safe — decorators are often called from multiple goroutines; use
sync.Mutexoratomicfor shared fields.- Only for cross-cutting concerns — logging, caching, metrics, retry, circuit breaker; not business logic, which belongs in a service or use case.