Skip to main content

Structural Design Patterns in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 7 min read

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

Comments

👍 Was this article helpful?