What is Proxy Pattern?

The Proxy pattern is a structural design pattern that provides a placeholder or surrogate for another object to control access to it. Think of it like a security guard at a building entrance - they control who can enter, when they can enter, and might even log who visited. The proxy acts as an intermediary that can add functionality like caching, logging, access control, or lazy loading without changing the original object.

I’ll show you how this pattern can add powerful capabilities to your Go applications while keeping the original objects unchanged.

Let’s start with a scenario: Database Connection Management

Imagine you’re building an application that needs to connect to an expensive database. You want to add features like connection pooling, query caching, access logging, and lazy connection establishment without modifying your existing database service code.

Without Proxy Pattern

Here’s how you might handle this without the Proxy pattern:

type DatabaseService struct {
    connectionString string
    connection       *sql.DB
    queryCache       map[string]interface{}
    accessLog        []string
}

func (d *DatabaseService) Connect() error {
    // Connection logic mixed with caching and logging
    if d.connection != nil {
        return nil // Already connected
    }
    
    log.Printf("Connecting to database: %s", d.connectionString)
    d.accessLog = append(d.accessLog, fmt.Sprintf("Connected at %s", time.Now()))
    
    // Actual connection logic
    conn, err := sql.Open("postgres", d.connectionString)
    if err != nil {
        return err
    }
    
    d.connection = conn
    return nil
}

func (d *DatabaseService) Query(query string) (interface{}, error) {
    // Query logic mixed with caching and logging
    d.accessLog = append(d.accessLog, fmt.Sprintf("Query: %s at %s", query, time.Now()))
    
    // Check cache
    if result, exists := d.queryCache[query]; exists {
        log.Printf("Cache hit for query: %s", query)
        return result, nil
    }
    
    // Ensure connection
    if err := d.Connect(); err != nil {
        return nil, err
    }
    
    // Execute query
    result, err := d.executeQuery(query)
    if err != nil {
        return nil, err
    }
    
    // Cache result
    d.queryCache[query] = result
    
    return result, nil
}

// This approach mixes concerns and makes the service complex

With Proxy Pattern

Let’s refactor this using the Proxy pattern:

package main

import (
    "fmt"
    "log"
    "sync"
    "time"
)

// Subject interface
type DatabaseService interface {
    Connect() error
    Query(query string) (interface{}, error)
    Close() error
}

// Real subject - the actual database service
type RealDatabaseService struct {
    connectionString string
    connected        bool
}

func NewRealDatabaseService(connectionString string) *RealDatabaseService {
    return &RealDatabaseService{
        connectionString: connectionString,
        connected:        false,
    }
}

func (r *RealDatabaseService) Connect() error {
    if r.connected {
        return nil
    }
    
    log.Printf("Establishing real database connection to: %s", r.connectionString)
    // Simulate connection time
    time.Sleep(100 * time.Millisecond)
    
    r.connected = true
    log.Printf("Database connection established")
    return nil
}

func (r *RealDatabaseService) Query(query string) (interface{}, error) {
    if !r.connected {
        return nil, fmt.Errorf("database not connected")
    }
    
    log.Printf("Executing query on real database: %s", query)
    // Simulate query execution
    time.Sleep(50 * time.Millisecond)
    
    // Return mock result
    return fmt.Sprintf("Result for: %s", query), nil
}

func (r *RealDatabaseService) Close() error {
    if !r.connected {
        return nil
    }
    
    log.Printf("Closing database connection")
    r.connected = false
    return nil
}

// Proxy with caching capability
type CachingDatabaseProxy struct {
    realService DatabaseService
    cache       map[string]interface{}
    cacheMutex  sync.RWMutex
    cacheExpiry map[string]time.Time
    ttl         time.Duration
}

func NewCachingDatabaseProxy(realService DatabaseService, ttl time.Duration) *CachingDatabaseProxy {
    return &CachingDatabaseProxy{
        realService: realService,
        cache:       make(map[string]interface{}),
        cacheExpiry: make(map[string]time.Time),
        ttl:         ttl,
    }
}

func (p *CachingDatabaseProxy) Connect() error {
    return p.realService.Connect()
}

func (p *CachingDatabaseProxy) Query(query string) (interface{}, error) {
    // Check cache first
    p.cacheMutex.RLock()
    if result, exists := p.cache[query]; exists {
        if expiry, hasExpiry := p.cacheExpiry[query]; hasExpiry {
            if time.Now().Before(expiry) {
                p.cacheMutex.RUnlock()
                log.Printf("Cache hit for query: %s", query)
                return result, nil
            } else {
                // Cache expired
                delete(p.cache, query)
                delete(p.cacheExpiry, query)
            }
        }
    }
    p.cacheMutex.RUnlock()
    
    // Cache miss - query real service
    log.Printf("Cache miss for query: %s", query)
    result, err := p.realService.Query(query)
    if err != nil {
        return nil, err
    }
    
    // Store in cache
    p.cacheMutex.Lock()
    p.cache[query] = result
    p.cacheExpiry[query] = time.Now().Add(p.ttl)
    p.cacheMutex.Unlock()
    
    return result, nil
}

func (p *CachingDatabaseProxy) Close() error {
    return p.realService.Close()
}

// Proxy with access control
type AccessControlProxy struct {
    realService   DatabaseService
    allowedUsers  map[string]bool
    currentUser   string
    accessLog     []string
    accessMutex   sync.Mutex
}

func NewAccessControlProxy(realService DatabaseService, allowedUsers []string) *AccessControlProxy {
    allowed := make(map[string]bool)
    for _, user := range allowedUsers {
        allowed[user] = true
    }
    
    return &AccessControlProxy{
        realService:  realService,
        allowedUsers: allowed,
        accessLog:    make([]string, 0),
    }
}

func (p *AccessControlProxy) SetCurrentUser(user string) {
    p.currentUser = user
}

func (p *AccessControlProxy) Connect() error {
    if !p.isAuthorized() {
        return fmt.Errorf("access denied for user: %s", p.currentUser)
    }
    
    p.logAccess("CONNECT")
    return p.realService.Connect()
}

func (p *AccessControlProxy) Query(query string) (interface{}, error) {
    if !p.isAuthorized() {
        return nil, fmt.Errorf("access denied for user: %s", p.currentUser)
    }
    
    p.logAccess(fmt.Sprintf("QUERY: %s", query))
    return p.realService.Query(query)
}

func (p *AccessControlProxy) Close() error {
    if !p.isAuthorized() {
        return fmt.Errorf("access denied for user: %s", p.currentUser)
    }
    
    p.logAccess("CLOSE")
    return p.realService.Close()
}

func (p *AccessControlProxy) isAuthorized() bool {
    return p.allowedUsers[p.currentUser]
}

func (p *AccessControlProxy) logAccess(action string) {
    p.accessMutex.Lock()
    defer p.accessMutex.Unlock()
    
    logEntry := fmt.Sprintf("[%s] User: %s, Action: %s", 
        time.Now().Format("2006-01-02 15:04:05"), p.currentUser, action)
    p.accessLog = append(p.accessLog, logEntry)
    log.Printf("ACCESS LOG: %s", logEntry)
}

func (p *AccessControlProxy) GetAccessLog() []string {
    p.accessMutex.Lock()
    defer p.accessMutex.Unlock()
    
    logCopy := make([]string, len(p.accessLog))
    copy(logCopy, p.accessLog)
    return logCopy
}

// Lazy loading proxy
type LazyDatabaseProxy struct {
    connectionString string
    realService      DatabaseService
    initialized      bool
    initMutex        sync.Mutex
}

func NewLazyDatabaseProxy(connectionString string) *LazyDatabaseProxy {
    return &LazyDatabaseProxy{
        connectionString: connectionString,
        initialized:      false,
    }
}

func (p *LazyDatabaseProxy) ensureInitialized() error {
    if p.initialized {
        return nil
    }
    
    p.initMutex.Lock()
    defer p.initMutex.Unlock()
    
    if p.initialized {
        return nil // Double-check after acquiring lock
    }
    
    log.Printf("Lazy initialization of database service")
    p.realService = NewRealDatabaseService(p.connectionString)
    p.initialized = true
    
    return nil
}

func (p *LazyDatabaseProxy) Connect() error {
    if err := p.ensureInitialized(); err != nil {
        return err
    }
    return p.realService.Connect()
}

func (p *LazyDatabaseProxy) Query(query string) (interface{}, error) {
    if err := p.ensureInitialized(); err != nil {
        return nil, err
    }
    return p.realService.Query(query)
}

func (p *LazyDatabaseProxy) Close() error {
    if !p.initialized {
        return nil // Nothing to close
    }
    return p.realService.Close()
}

// Composite proxy combining multiple proxy behaviors
type CompositeDatabaseProxy struct {
    DatabaseService
}

func NewCompositeDatabaseProxy(connectionString string, allowedUsers []string, cacheTTL time.Duration) DatabaseService {
    // Create the real service
    realService := NewRealDatabaseService(connectionString)
    
    // Wrap with lazy loading
    lazyProxy := NewLazyDatabaseProxy(connectionString)
    
    // Wrap with caching
    cachingProxy := NewCachingDatabaseProxy(lazyProxy, cacheTTL)
    
    // Wrap with access control
    accessProxy := NewAccessControlProxy(cachingProxy, allowedUsers)
    
    return &CompositeDatabaseProxy{
        DatabaseService: accessProxy,
    }
}

func main() {
    // Create a composite proxy with multiple behaviors
    allowedUsers := []string{"admin", "user1", "user2"}
    cacheTTL := 5 * time.Minute
    
    dbService := NewCompositeDatabaseProxy("postgres://localhost:5432/mydb", allowedUsers, cacheTTL)
    
    // Set current user (for access control proxy)
    if accessProxy, ok := dbService.(*CompositeDatabaseProxy).DatabaseService.(*AccessControlProxy); ok {
        accessProxy.SetCurrentUser("admin")
    }
    
    // Use the service - all proxy behaviors are applied transparently
    fmt.Println("=== Testing Database Service with Proxy ===")
    
    err := dbService.Connect()
    if err != nil {
        log.Fatalf("Failed to connect: %v", err)
    }
    
    // First query - will be cached
    result1, err := dbService.Query("SELECT * FROM users")
    if err != nil {
        log.Fatalf("Query failed: %v", err)
    }
    fmt.Printf("Result 1: %v\n", result1)
    
    // Second query - should hit cache
    result2, err := dbService.Query("SELECT * FROM users")
    if err != nil {
        log.Fatalf("Query failed: %v", err)
    }
    fmt.Printf("Result 2: %v\n", result2)
    
    // Different query
    result3, err := dbService.Query("SELECT * FROM products")
    if err != nil {
        log.Fatalf("Query failed: %v", err)
    }
    fmt.Printf("Result 3: %v\n", result3)
    
    err = dbService.Close()
    if err != nil {
        log.Fatalf("Failed to close: %v", err)
    }
    
    // Test access control
    fmt.Println("\n=== Testing Access Control ===")
    if accessProxy, ok := dbService.(*CompositeDatabaseProxy).DatabaseService.(*AccessControlProxy); ok {
        accessProxy.SetCurrentUser("unauthorized_user")
        
        err := dbService.Connect()
        if err != nil {
            fmt.Printf("Expected access denied: %v\n", err)
        }
        
        // Show access log
        fmt.Println("\nAccess Log:")
        for _, entry := range accessProxy.GetAccessLog() {
            fmt.Println(entry)
        }
    }
}

Virtual Proxy for Expensive Resources

Here’s another common use case - virtual proxy for expensive resource loading:

type ImageService interface {
    Display() error
    GetSize() (int, int)
}

type RealImage struct {
    filename string
    data     []byte
    width    int
    height   int
}

func NewRealImage(filename string) *RealImage {
    img := &RealImage{filename: filename}
    img.loadFromDisk()
    return img
}

func (r *RealImage) loadFromDisk() {
    log.Printf("Loading expensive image from disk: %s", r.filename)
    // Simulate expensive loading operation
    time.Sleep(200 * time.Millisecond)
    
    // Mock image data
    r.data = make([]byte, 1024*1024) // 1MB
    r.width = 1920
    r.height = 1080
    log.Printf("Image loaded: %dx%d", r.width, r.height)
}

func (r *RealImage) Display() error {
    log.Printf("Displaying image: %s", r.filename)
    return nil
}

func (r *RealImage) GetSize() (int, int) {
    return r.width, r.height
}

type VirtualImageProxy struct {
    filename  string
    realImage *RealImage
    loaded    bool
}

func NewVirtualImageProxy(filename string) *VirtualImageProxy {
    return &VirtualImageProxy{
        filename: filename,
        loaded:   false,
    }
}

func (v *VirtualImageProxy) ensureLoaded() {
    if !v.loaded {
        log.Printf("Virtual proxy: Loading image on demand")
        v.realImage = NewRealImage(v.filename)
        v.loaded = true
    }
}

func (v *VirtualImageProxy) Display() error {
    v.ensureLoaded()
    return v.realImage.Display()
}

func (v *VirtualImageProxy) GetSize() (int, int) {
    v.ensureLoaded()
    return v.realImage.GetSize()
}

Real-world Use Cases

Here’s where I commonly use the Proxy pattern in Go:

  1. HTTP Clients: Adding retry logic, rate limiting, and caching to API calls
  2. Database Connections: Connection pooling, query caching, and access control
  3. File Systems: Lazy loading, caching, and access control for file operations
  4. Remote Services: Adding circuit breakers and fallback mechanisms
  5. Resource Management: Controlling access to expensive resources like memory or CPU

Benefits of Proxy Pattern

  1. Transparency: Clients use proxies the same way as real objects
  2. Lazy Loading: Expensive operations can be deferred until needed
  3. Access Control: Can add security and authorization layers
  4. Caching: Can add performance improvements through caching
  5. Separation of Concerns: Keeps additional functionality separate from core logic

Caveats

While the Proxy pattern is powerful, consider these limitations:

  1. Complexity: Adds additional layers that can complicate debugging
  2. Performance: May introduce overhead, especially with multiple proxy layers
  3. Memory Usage: Proxies consume additional memory
  4. Interface Coupling: Proxy must implement the same interface as the real object
  5. Maintenance: Changes to the real object interface require proxy updates

Thank you

Thank you for reading! The Proxy pattern is incredibly versatile and useful for adding cross-cutting concerns to your Go applications. It’s particularly powerful when you need to add functionality like caching, security, or lazy loading without modifying existing code. Please drop an email at [email protected] if you would like to share any feedback or suggestions. Peace!