Structural patterns deal with how objects and types are composed into larger structures. In Go, composition happens primarily through embedding and interfaces — without inheritance. This makes several classical patterns simpler to implement than in OOP languages, and a few others unnecessary.
The patterns most relevant to day-to-day Go code are Adapter (interface compatibility), Decorator (layered behavior — the HTTP middleware pattern), Facade (hiding complexity behind a simple interface), and Proxy (controlled access, caching, logging).
For behavioral patterns see Go behavioral design patterns and for creational patterns see Go creational design patterns.
Adapter: Making Incompatible Interfaces Work Together
The Adapter pattern bridges two interfaces that can’t directly interact. The most common real-world case in Go: wrapping a third-party library’s type to satisfy your own interface, or making an old API compatible with a new one.
A concrete example — a legacy logger that takes (level, message string) but your codebase expects slog.Handler:
// Legacy logger you can't change
type LegacyLogger struct{}
func (l *LegacyLogger) LogMsg(level, message string) {
fmt.Printf("[%s] %s\n", level, message)
}
// New interface your codebase uses
type Logger interface {
Info(msg string, args ...any)
Error(msg string, args ...any)
}
// Adapter wraps the legacy type to satisfy the new interface
type LegacyLoggerAdapter struct {
legacy *LegacyLogger
}
func (a *LegacyLoggerAdapter) Info(msg string, args ...any) {
a.legacy.LogMsg("INFO", fmt.Sprintf(msg, args...))
}
func (a *LegacyLoggerAdapter) Error(msg string, args ...any) {
a.legacy.LogMsg("ERROR", fmt.Sprintf(msg, args...))
}
// Usage — LegacyLoggerAdapter satisfies Logger interface
var log Logger = &LegacyLoggerAdapter{legacy: &LegacyLogger{}}
log.Info("server started on port %d", 8080)
Another common case: adapting a payment processor’s API to a generic PaymentGateway interface, so you can swap providers without changing business logic:
type PaymentGateway interface {
Charge(ctx context.Context, amount int, currency, token string) (string, error)
Refund(ctx context.Context, chargeID string, amount int) error
}
// Stripe's actual API looks different — the adapter bridges the gap
type StripeAdapter struct{ client *stripe.Client }
func (a *StripeAdapter) Charge(ctx context.Context, amount int, currency, token string) (string, error) {
charge, err := a.client.Charges.New(&stripe.ChargeParams{
Amount: stripe.Int64(int64(amount)),
Currency: stripe.String(currency),
Source: &stripe.SourceParams{Token: stripe.String(token)},
})
if err != nil { return "", err }
return charge.ID, nil
}
Decorator: Wrapping Behavior Transparently
The Decorator adds behavior to an object without modifying it. In Go, this is exactly what HTTP middleware does — wrap an http.Handler with additional behavior that the handler doesn’t know about:
// Each decorator wraps an http.Handler and is itself an http.Handler
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &statusWriter{ResponseWriter: w, code: 200}
next.ServeHTTP(rw, r)
slog.Info("request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rw.code),
slog.Duration("latency", time.Since(start)),
)
})
}
func withAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// Chain decorators — each wraps the next
handler := withLogging(withAuth(myHandler))
The same pattern works for any interface. A caching decorator for a data store:
type UserStore interface {
GetUser(ctx context.Context, id string) (*User, error)
}
type CachingUserStore struct {
store UserStore
cache map[string]*User
mu sync.RWMutex
ttl time.Duration
}
func (c *CachingUserStore) GetUser(ctx context.Context, id string) (*User, error) {
c.mu.RLock()
if u, ok := c.cache[id]; ok {
c.mu.RUnlock()
return u, nil // cache hit
}
c.mu.RUnlock()
u, err := c.store.GetUser(ctx, id) // delegate to real store
if err != nil { return nil, err }
c.mu.Lock()
c.cache[id] = u
c.mu.Unlock()
return u, nil
}
CachingUserStore satisfies UserStore and wraps another UserStore — the real database implementation doesn’t know it’s being cached. Swap in the caching layer by changing one line in the constructor.
Facade: Hiding Complexity Behind a Simple Interface
A Facade simplifies a complex subsystem by providing a single, clean interface. The client interacts only with the facade — it doesn’t need to know about the underlying components.
An order processing system has multiple collaborators. The facade orchestrates them:
type OrderFacade struct {
inventory InventoryService
payment PaymentService
shipping ShippingService
email EmailService
}
// PlaceOrder: single method that hides the multi-step coordination
func (f *OrderFacade) PlaceOrder(ctx context.Context, req OrderRequest) (*Order, error) {
// Step 1: Check and reserve inventory
if err := f.inventory.Reserve(ctx, req.Items); err != nil {
return nil, fmt.Errorf("inventory: %w", err)
}
// Step 2: Process payment
txnID, err := f.payment.Charge(ctx, req.PaymentToken, req.Total)
if err != nil {
f.inventory.Release(ctx, req.Items) // rollback inventory
return nil, fmt.Errorf("payment: %w", err)
}
// Step 3: Create shipment
trackingID, err := f.shipping.CreateShipment(ctx, req.ShipTo, req.Items)
if err != nil {
// Non-critical — log but don't fail the order
slog.Error("shipping failed", slog.Any("error", err))
}
order := &Order{
TransactionID: txnID,
TrackingID: trackingID,
Items: req.Items,
}
// Step 4: Send confirmation (fire and forget)
go f.email.SendConfirmation(ctx, req.CustomerEmail, order)
return order, nil
}
The HTTP handler calls facade.PlaceOrder(ctx, req) — one line. All the coordination, error handling, and rollback logic lives inside the facade. The handler doesn’t need to import the inventory, payment, or shipping packages.
Proxy: Controlled Access to an Object
A Proxy controls access to another object — adding logging, caching, access control, lazy initialization, or rate limiting without the real object knowing:
// Virtual Proxy: lazy initialization — the expensive object is created only on first use
type LazyDBProxy struct {
dsn string
db *sql.DB
mu sync.Mutex
}
func (p *LazyDBProxy) getDB() (*sql.DB, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.db == nil {
db, err := sql.Open("postgres", p.dsn)
if err != nil { return nil, err }
p.db = db
}
return p.db, nil
}
func (p *LazyDBProxy) QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error) {
db, err := p.getDB()
if err != nil { return nil, err }
return db.QueryContext(ctx, q, args...)
}
// Protection Proxy: add access control
type SecureUserStore struct {
store UserStore
authz AuthorizationService
}
func (s *SecureUserStore) GetUser(ctx context.Context, id string) (*User, error) {
caller := callerFromContext(ctx)
if !s.authz.CanRead(caller, "users", id) {
return nil, ErrForbidden
}
return s.store.GetUser(ctx, id)
}
// Logging Proxy: record every operation
type LoggingUserStore struct {
store UserStore
log *slog.Logger
}
func (l *LoggingUserStore) GetUser(ctx context.Context, id string) (*User, error) {
start := time.Now()
u, err := l.store.GetUser(ctx, id)
l.log.InfoContext(ctx, "GetUser",
slog.String("id", id),
slog.Duration("latency", time.Since(start)),
slog.Bool("found", u != nil),
slog.Any("error", err),
)
return u, err
}
All three proxies satisfy UserStore — they’re interchangeable with the real store from the caller’s perspective.
Composite: Uniform Treatment of Individual and Group
Composite lets you treat individual items and collections of items through the same interface. This is natural for tree structures: file systems, UI components, expression trees.
type FileNode interface {
Name() string
Size() int64
Print(indent string)
}
// Leaf: a regular file
type File struct {
name string
size int64
}
func (f *File) Name() string { return f.name }
func (f *File) Size() int64 { return f.size }
func (f *File) Print(indent string) {
fmt.Printf("%s%s (%d bytes)\n", indent, f.name, f.size)
}
// Composite: a directory that contains other FileNodes
type Directory struct {
name string
children []FileNode
}
func (d *Directory) Name() string { return d.name }
func (d *Directory) Size() int64 {
var total int64
for _, c := range d.children { total += c.Size() }
return total
}
func (d *Directory) Print(indent string) {
fmt.Printf("%s%s/ (%d bytes)\n", indent, d.name, d.Size())
for _, c := range d.children {
c.Print(indent + " ")
}
}
func (d *Directory) Add(node FileNode) { d.children = append(d.children, node) }
// Usage — uniform treatment regardless of leaf vs composite
root := &Directory{name: "project"}
src := &Directory{name: "src"}
src.Add(&File{name: "main.go", size: 1024})
src.Add(&File{name: "handlers.go", size: 4096})
root.Add(src)
root.Add(&File{name: "README.md", size: 512})
root.Print("")
// project/ (5632 bytes)
// src/ (5120 bytes)
// main.go (1024 bytes)
// handlers.go (4096 bytes)
// README.md (512 bytes)
Choosing the Right Pattern
| Pattern | Use when |
|---|---|
| Adapter | Wrapping a third-party API to match your internal interface |
| Decorator | Adding cross-cutting behavior (logging, caching, auth) without modifying the wrapped type |
| Facade | Hiding multi-step coordination behind a single clean method |
| Proxy | Controlling access to an object (lazy init, access control, rate limiting) |
| Composite | Treating individual items and collections uniformly (tree structures) |
Go’s interfaces make Adapter, Decorator, and Proxy particularly clean — the wrapped type and the wrapper satisfy the same interface, so no additional boilerplate is required.
Summary
- Adapter: wrap a type to satisfy a different interface — common for third-party library integration
- Decorator: wrap an interface implementation to add behavior — the pattern underlying all HTTP middleware
- Facade: provide a single method that orchestrates multiple services — keeps handlers and clients simple
- Proxy: same interface as the real object, but with added behavior (caching, auth, logging) before/after delegation
- Composite: recursive structures where individual items and groups satisfy the same interface
Resources
- Refactoring Guru: Structural patterns in Go
- Go Design Patterns (tmrts)
- Go behavioral design patterns
Comments