Proxy Pattern in Go: Interface-Based Proxies

The proxy pattern places an intermediary object between a client and a real subject, controlling access to the underlying implementation. The client interacts with the proxy exactly as it would with...

Key Insights

  • Go’s implicit interface satisfaction makes proxies remarkably clean—any struct that implements the same methods as the target automatically satisfies the interface, requiring no inheritance hierarchies or explicit declarations.
  • The proxy pattern shines for cross-cutting concerns like logging, caching, and access control, letting you add behavior without modifying existing code or cluttering business logic.
  • Thread-safe lazy initialization using sync.Once is the idiomatic Go approach for virtual proxies, avoiding the double-checked locking pitfalls common in other languages.

Introduction to the Proxy Pattern

The proxy pattern places an intermediary object between a client and a real subject, controlling access to the underlying implementation. The client interacts with the proxy exactly as it would with the real object—same interface, same method signatures—but the proxy can intercept calls to add behavior: lazy loading, access control, logging, caching, or any other cross-cutting concern.

Go’s interface system makes this pattern particularly elegant. Unlike languages requiring explicit interface implementation, Go uses structural typing. If your proxy struct has the right methods, it satisfies the interface automatically. No boilerplate declarations, no inheritance chains.

Throughout this article, we’ll work with a simple storage interface:

type Storage interface {
    Get(key string) ([]byte, error)
    Put(key string, value []byte) error
    Delete(key string) error
}

This interface represents any key-value storage system. Our real implementation might be a database, a remote service, or a file system. The proxy pattern lets us wrap any of these with additional behavior without changing a single line in the original implementation.

Anatomy of an Interface-Based Proxy in Go

Every proxy implementation involves three components: the subject interface (what both the real object and proxy implement), the real subject (the actual implementation doing the work), and the proxy (the wrapper adding behavior).

Here’s the basic structure:

// Real implementation
type FileStorage struct {
    basePath string
}

func (f *FileStorage) Get(key string) ([]byte, error) {
    return os.ReadFile(filepath.Join(f.basePath, key))
}

func (f *FileStorage) Put(key string, value []byte) error {
    return os.WriteFile(filepath.Join(f.basePath, key), value, 0644)
}

func (f *FileStorage) Delete(key string) error {
    return os.Remove(filepath.Join(f.basePath, key))
}

// Proxy wrapping the real implementation
type StorageProxy struct {
    real Storage
}

func NewStorageProxy(real Storage) *StorageProxy {
    return &StorageProxy{real: real}
}

func (p *StorageProxy) Get(key string) ([]byte, error) {
    // Add behavior before
    result, err := p.real.Get(key)
    // Add behavior after
    return result, err
}

func (p *StorageProxy) Put(key string, value []byte) error {
    return p.real.Put(key, value)
}

func (p *StorageProxy) Delete(key string) error {
    return p.real.Delete(key)
}

The proxy holds a reference to the real implementation and delegates all calls to it. The magic happens in those comment blocks—that’s where you inject your cross-cutting behavior.

You might consider struct embedding to reduce boilerplate, but I advise against it for proxies. Embedding promotes the embedded type’s methods directly, which means you’d need to explicitly override every method you want to intercept. Explicit delegation is more verbose but makes the proxy’s behavior obvious and prevents accidental pass-through of unproxied methods.

Virtual Proxy: Lazy Initialization

Virtual proxies defer expensive object creation until the object is actually needed. This is invaluable when initialization is costly—database connections, network clients, or large data structures that might never be used.

Here’s a thread-safe lazy-loading proxy using sync.Once:

type LazyStorage struct {
    factory func() (Storage, error)
    real    Storage
    once    sync.Once
    initErr error
}

func NewLazyStorage(factory func() (Storage, error)) *LazyStorage {
    return &LazyStorage{factory: factory}
}

func (l *LazyStorage) init() {
    l.real, l.initErr = l.factory()
}

func (l *LazyStorage) Get(key string) ([]byte, error) {
    l.once.Do(l.init)
    if l.initErr != nil {
        return nil, fmt.Errorf("storage initialization failed: %w", l.initErr)
    }
    return l.real.Get(key)
}

func (l *LazyStorage) Put(key string, value []byte) error {
    l.once.Do(l.init)
    if l.initErr != nil {
        return fmt.Errorf("storage initialization failed: %w", l.initErr)
    }
    return l.real.Put(key, value)
}

func (l *LazyStorage) Delete(key string) error {
    l.once.Do(l.init)
    if l.initErr != nil {
        return fmt.Errorf("storage initialization failed: %w", l.initErr)
    }
    return l.real.Delete(key)
}

Usage defers the expensive database connection until the first actual operation:

storage := NewLazyStorage(func() (Storage, error) {
    // This expensive connection only happens on first use
    return ConnectToDatabase("postgres://localhost/mydb")
})

// No connection yet...
time.Sleep(10 * time.Second)

// NOW it connects
data, err := storage.Get("user:123")

The sync.Once guarantees the factory runs exactly once, even under concurrent access. This is the idiomatic Go solution—don’t roll your own double-checked locking.

Protection Proxy: Access Control

Protection proxies add authorization checks before allowing operations. They’re perfect for enforcing security policies without polluting business logic with permission checks.

type Permission int

const (
    PermissionRead Permission = 1 << iota
    PermissionWrite
    PermissionDelete
)

type User struct {
    ID          string
    Permissions Permission
}

type ProtectedStorage struct {
    real        Storage
    currentUser func() *User
}

func NewProtectedStorage(real Storage, userProvider func() *User) *ProtectedStorage {
    return &ProtectedStorage{
        real:        real,
        currentUser: userProvider,
    }
}

func (p *ProtectedStorage) checkPermission(required Permission) error {
    user := p.currentUser()
    if user == nil {
        return errors.New("authentication required")
    }
    if user.Permissions&required == 0 {
        return fmt.Errorf("user %s lacks required permission", user.ID)
    }
    return nil
}

func (p *ProtectedStorage) Get(key string) ([]byte, error) {
    if err := p.checkPermission(PermissionRead); err != nil {
        return nil, err
    }
    return p.real.Get(key)
}

func (p *ProtectedStorage) Put(key string, value []byte) error {
    if err := p.checkPermission(PermissionWrite); err != nil {
        return err
    }
    return p.real.Put(key, value)
}

func (p *ProtectedStorage) Delete(key string) error {
    if err := p.checkPermission(PermissionDelete); err != nil {
        return err
    }
    return p.real.Delete(key)
}

The user provider function allows flexible integration with your authentication system—pull from context, session, JWT, whatever fits your architecture.

Logging and Caching Proxies

Logging and caching are the workhorses of production systems. Here’s a proxy that combines both:

type CachedLoggingStorage struct {
    real   Storage
    cache  map[string]cachedItem
    mu     sync.RWMutex
    ttl    time.Duration
    logger *slog.Logger
}

type cachedItem struct {
    value     []byte
    expiresAt time.Time
}

func NewCachedLoggingStorage(real Storage, ttl time.Duration, logger *slog.Logger) *CachedLoggingStorage {
    return &CachedLoggingStorage{
        real:   real,
        cache:  make(map[string]cachedItem),
        ttl:    ttl,
        logger: logger,
    }
}

func (c *CachedLoggingStorage) Get(key string) ([]byte, error) {
    start := time.Now()
    
    // Check cache first
    c.mu.RLock()
    if item, ok := c.cache[key]; ok && time.Now().Before(item.expiresAt) {
        c.mu.RUnlock()
        c.logger.Info("cache hit", "key", key, "duration", time.Since(start))
        return item.value, nil
    }
    c.mu.RUnlock()
    
    // Cache miss - fetch from real storage
    value, err := c.real.Get(key)
    duration := time.Since(start)
    
    if err != nil {
        c.logger.Error("get failed", "key", key, "duration", duration, "error", err)
        return nil, err
    }
    
    // Update cache
    c.mu.Lock()
    c.cache[key] = cachedItem{
        value:     value,
        expiresAt: time.Now().Add(c.ttl),
    }
    c.mu.Unlock()
    
    c.logger.Info("cache miss", "key", key, "duration", duration)
    return value, nil
}

func (c *CachedLoggingStorage) Put(key string, value []byte) error {
    start := time.Now()
    err := c.real.Put(key, value)
    
    // Invalidate cache on write
    c.mu.Lock()
    delete(c.cache, key)
    c.mu.Unlock()
    
    c.logger.Info("put", "key", key, "duration", time.Since(start), "error", err)
    return err
}

func (c *CachedLoggingStorage) Delete(key string) error {
    start := time.Now()
    err := c.real.Delete(key)
    
    c.mu.Lock()
    delete(c.cache, key)
    c.mu.Unlock()
    
    c.logger.Info("delete", "key", key, "duration", time.Since(start), "error", err)
    return err
}

Because proxies share the same interface, you can compose them:

var storage Storage = &FileStorage{basePath: "/data"}
storage = NewCachedLoggingStorage(storage, 5*time.Minute, logger)
storage = NewProtectedStorage(storage, getCurrentUser)

Each layer adds its behavior transparently. The order matters—here, protection checks happen before logging and caching.

Testing with Proxy Patterns

Proxies excel in testing scenarios. Here’s a recording proxy that captures calls for assertions:

type RecordingStorage struct {
    real  Storage
    calls []Call
    mu    sync.Mutex
}

type Call struct {
    Method string
    Args   []any
    Result any
    Error  error
}

func NewRecordingStorage(real Storage) *RecordingStorage {
    return &RecordingStorage{real: real}
}

func (r *RecordingStorage) Get(key string) ([]byte, error) {
    result, err := r.real.Get(key)
    r.mu.Lock()
    r.calls = append(r.calls, Call{
        Method: "Get",
        Args:   []any{key},
        Result: result,
        Error:  err,
    })
    r.mu.Unlock()
    return result, err
}

func (r *RecordingStorage) Calls() []Call {
    r.mu.Lock()
    defer r.mu.Unlock()
    return append([]Call{}, r.calls...)
}

// Use in tests
func TestUserService(t *testing.T) {
    recorder := NewRecordingStorage(&InMemoryStorage{})
    service := NewUserService(recorder)
    
    service.GetUser("123")
    
    calls := recorder.Calls()
    if len(calls) != 1 || calls[0].Method != "Get" {
        t.Errorf("expected single Get call, got %v", calls)
    }
}

This differs from mocks—the recording proxy uses a real implementation (or another test double) while capturing interactions. It’s particularly useful for integration tests where you want real behavior but need to verify specific interactions occurred.

Trade-offs and Best Practices

Proxies aren’t always the right choice. They add indirection, which complicates debugging and stack traces. For simple cases, a function wrapper might suffice. For HTTP-specific concerns, middleware is more idiomatic.

Consider proxies when:

  • You need to add behavior to an interface you don’t control
  • Multiple implementations need the same cross-cutting behavior
  • You want composable, stackable behaviors
  • The behavior genuinely belongs at the interface boundary

Skip proxies when:

  • A simple function wrapper handles the case
  • You’re only wrapping one specific implementation
  • The framework provides better abstractions (HTTP middleware, gRPC interceptors)
  • The added indirection hurts debuggability more than it helps

Performance-wise, proxy overhead is typically negligible—a few nanoseconds for the extra method call. The real costs come from the behaviors you add: caching logic, logging I/O, permission database lookups. Profile those, not the proxy pattern itself.

Go’s interfaces make proxies a natural fit for the language. Use them to keep your implementations focused on business logic while proxies handle the operational concerns. Just don’t reach for them reflexively—sometimes the simplest solution is the right one.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.