Decorator Pattern in Go: Interface Wrapping
The decorator pattern lets you add behavior to objects without modifying their source code. You wrap an existing implementation with a new struct that implements the same interface, intercepts calls,...
Key Insights
- Go’s implicit interface satisfaction makes the decorator pattern feel native—any struct that wraps an interface and implements the same methods becomes a decorator without inheritance or special syntax.
- Decorator order matters significantly: a retry decorator wrapping a timeout decorator behaves very differently than the reverse, so design your composition chain deliberately.
- Keep your interfaces small (1-3 methods) to make decoration practical; large interfaces create maintenance nightmares when every decorator must implement dozens of methods.
Introduction: What is the Decorator Pattern?
The decorator pattern lets you add behavior to objects without modifying their source code. You wrap an existing implementation with a new struct that implements the same interface, intercepts calls, adds functionality, and delegates to the original.
This pattern aligns perfectly with Go’s philosophy. Go doesn’t have inheritance, and that’s a feature, not a limitation. Instead of building deep class hierarchies where behavior gets tangled across parent and child classes, Go encourages composition. The decorator pattern is composition in its purest form: small, focused wrappers that each do one thing well.
When you need logging, you don’t modify your HTTP client. When you need retries, you don’t touch your database connection. You wrap them. The original code stays clean, testable, and blissfully unaware of the concerns layered on top.
The Go Way: Interfaces Enable Decoration
In languages like Java, implementing the decorator pattern requires explicit interface declarations, abstract classes, and careful type hierarchies. Go’s implicit interface satisfaction eliminates this ceremony entirely.
If your struct has the right methods, it implements the interface. No implements keyword. No registration. This makes decoration feel natural rather than architectural.
// HTTPClient defines what we need from an HTTP client
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
// The standard library's http.Client already satisfies this interface.
// No modifications needed. No adapters required.
var _ HTTPClient = (*http.Client)(nil) // compile-time verification
This small interface is the foundation. The standard library’s http.Client satisfies it without knowing our interface exists. Any decorator we build will also satisfy it, making them interchangeable and stackable.
Compare this to classical OOP where you’d need an abstract HTTPClientDecorator base class, explicit interface implementations, and careful attention to which methods call super. Go’s approach is simpler: just wrap and delegate.
Building Your First Decorator
Let’s build a logging decorator step by step. The pattern is consistent: create a struct that holds the interface you’re decorating, implement the same interface methods, add your behavior, and delegate.
// LoggingClient wraps an HTTPClient and logs request details
type LoggingClient struct {
wrapped HTTPClient
logger *slog.Logger
}
// NewLoggingClient creates a logging decorator
func NewLoggingClient(client HTTPClient, logger *slog.Logger) *LoggingClient {
return &LoggingClient{
wrapped: client,
logger: logger,
}
}
// Do implements HTTPClient, adding logging around the actual request
func (c *LoggingClient) Do(req *http.Request) (*http.Response, error) {
start := time.Now()
c.logger.Info("request started",
"method", req.Method,
"url", req.URL.String(),
)
resp, err := c.wrapped.Do(req)
duration := time.Since(start)
if err != nil {
c.logger.Error("request failed",
"method", req.Method,
"url", req.URL.String(),
"duration", duration,
"error", err,
)
return nil, err
}
c.logger.Info("request completed",
"method", req.Method,
"url", req.URL.String(),
"status", resp.StatusCode,
"duration", duration,
)
return resp, nil
}
Notice we use an explicit field (wrapped) rather than embedding. Embedding would expose the inner client’s methods directly, which can cause confusion about which implementation handles a call. Explicit fields make the delegation obvious and intentional.
Stacking Decorators: Composable Behavior
The real power emerges when you stack decorators. Each layer adds one concern, and the composition creates sophisticated behavior from simple parts.
// RetryClient retries failed requests with exponential backoff
type RetryClient struct {
wrapped HTTPClient
maxRetries int
baseDelay time.Duration
}
func NewRetryClient(client HTTPClient, maxRetries int, baseDelay time.Duration) *RetryClient {
return &RetryClient{
wrapped: client,
maxRetries: maxRetries,
baseDelay: baseDelay,
}
}
func (c *RetryClient) Do(req *http.Request) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
delay := c.baseDelay * time.Duration(1<<(attempt-1))
time.Sleep(delay)
}
resp, err := c.wrapped.Do(req)
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
if err != nil {
lastErr = err
} else {
lastErr = fmt.Errorf("server error: %d", resp.StatusCode)
resp.Body.Close()
}
}
return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
}
// TimeoutClient enforces a per-request timeout
type TimeoutClient struct {
wrapped HTTPClient
timeout time.Duration
}
func NewTimeoutClient(client HTTPClient, timeout time.Duration) *TimeoutClient {
return &TimeoutClient{
wrapped: client,
timeout: timeout,
}
}
func (c *TimeoutClient) Do(req *http.Request) (*http.Response, error) {
ctx, cancel := context.WithTimeout(req.Context(), c.timeout)
defer cancel()
return c.wrapped.Do(req.WithContext(ctx))
}
Now compose them:
func BuildClient(logger *slog.Logger) HTTPClient {
base := &http.Client{}
// Order matters! Read from inside out:
// 1. Timeout applies to each individual attempt
// 2. Retry wraps timeout, so each retry gets its own timeout
// 3. Logging wraps everything, capturing the full operation
withTimeout := NewTimeoutClient(base, 5*time.Second)
withRetry := NewRetryClient(withTimeout, 3, 100*time.Millisecond)
withLogging := NewLoggingClient(withRetry, logger)
return withLogging
}
The order here is critical. If you wrapped retry around logging instead, you’d get a log entry for each retry attempt. Sometimes that’s what you want. Sometimes it’s noise. Think through your composition order deliberately.
Real-World Use Cases
The decorator pattern appears throughout production Go code, often under different names.
Middleware in web frameworks is decoration applied to HTTP handlers. Each middleware wraps the next handler, adding authentication, logging, panic recovery, or request tracing.
Metrics collection decorates services to track latency, error rates, and throughput without polluting business logic:
// MetricsClient tracks request metrics
type MetricsClient struct {
wrapped HTTPClient
requests *prometheus.CounterVec
duration *prometheus.HistogramVec
}
func NewMetricsClient(client HTTPClient, reg prometheus.Registerer) *MetricsClient {
requests := prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_client_requests_total",
Help: "Total HTTP requests made",
},
[]string{"method", "status"},
)
duration := prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_client_request_duration_seconds",
Help: "HTTP request duration in seconds",
Buckets: prometheus.DefBuckets,
},
[]string{"method"},
)
reg.MustRegister(requests, duration)
return &MetricsClient{
wrapped: client,
requests: requests,
duration: duration,
}
}
func (c *MetricsClient) Do(req *http.Request) (*http.Response, error) {
start := time.Now()
resp, err := c.wrapped.Do(req)
duration := time.Since(start).Seconds()
c.duration.WithLabelValues(req.Method).Observe(duration)
status := "error"
if err == nil {
status = strconv.Itoa(resp.StatusCode)
}
c.requests.WithLabelValues(req.Method, status).Inc()
return resp, err
}
Caching layers wrap data access to serve repeated queries from memory. Circuit breakers wrap external calls to fail fast when downstream services are unhealthy. Authentication decorators inject credentials into requests. Each concern stays isolated in its own decorator.
Pitfalls and Best Practices
Avoid decorator explosion. When you have seven decorators stacked, debugging becomes archaeology. If you find yourself with deep stacks, consider whether some concerns should merge or whether a different pattern fits better.
Keep interfaces small. An interface with fifteen methods means every decorator implements fifteen methods, most just delegating. The io.Reader interface has one method. The http.Handler interface has one method. Follow their lead.
Test at multiple levels. Test your base implementation in isolation. Test individual decorators with mock wrapped implementations. Test the composed stack for integration:
func TestLoggingClient(t *testing.T) {
tests := []struct {
name string
mockResponse *http.Response
mockError error
wantLogLevel slog.Level
}{
{
name: "successful request logs info",
mockResponse: &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(""))},
mockError: nil,
wantLogLevel: slog.LevelInfo,
},
{
name: "failed request logs error",
mockResponse: nil,
mockError: errors.New("connection refused"),
wantLogLevel: slog.LevelError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var logBuffer bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&logBuffer, nil))
mock := &mockHTTPClient{
response: tt.mockResponse,
err: tt.mockError,
}
client := NewLoggingClient(mock, logger)
req, _ := http.NewRequest("GET", "http://example.com", nil)
client.Do(req)
// Verify log output contains expected level
if !strings.Contains(logBuffer.String(), tt.wantLogLevel.String()) {
t.Errorf("expected log level %s in output: %s",
tt.wantLogLevel, logBuffer.String())
}
})
}
}
type mockHTTPClient struct {
response *http.Response
err error
}
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
return m.response, m.err
}
Conclusion: When to Reach for Decoration
Use the decorator pattern when you need to add cross-cutting concerns—logging, metrics, retries, caching—without modifying existing code. It shines when behavior should be optional, composable, or configurable at runtime.
The pattern enforces the Single Responsibility Principle: each decorator does one thing. It supports the Open/Closed Principle: you extend behavior without modifying existing implementations. And it improves testability: you can test each layer independently with mock dependencies.
Don’t use decoration when the added behavior is intrinsic to the type’s purpose, when you need to modify return types, or when the interface is too large to wrap practically. In those cases, consider embedding, functional options, or simply modifying the original code.
Go’s interfaces make decoration lightweight and idiomatic. Master this pattern, and you’ll write cleaner, more maintainable systems.