Ilya Brin - Software Engineer

History is written by its contributors

Proxy Pattern in Go: Access Control and Resource Management

2025-08-12 9 min read Development Ilya Brin

Proxy pattern provides a surrogate or placeholder for another object to control access to it. It acts as an intermediary, adding functionality without changing the original object.

Here’s when and how to use it in real-world Go applications.

The Problem

You have an object that’s expensive to create, requires access control, or needs additional functionality. Direct access causes issues:

Scenario 1: Database Connection

Your application connects to PostgreSQL. Each connection consumes memory and network resources. Creating connections for every request is wasteful. You need connection pooling, but clients shouldn’t know about it.

Scenario 2: External API

You’re calling a third-party payment API. Each call costs money. You need caching, rate limiting, and logging. But you don’t want to modify the API client code every time requirements change.

Scenario 3: Sensitive Resources

Your service accesses user data. Not all users have permission to access all data. You need authorization checks before every access. But business logic shouldn’t be cluttered with security code.

Common thread: You need to control access to an object without changing how clients use it.

What is Proxy Pattern

Proxy pattern creates an intermediary object that:

  • Implements the same interface as the real object
  • Controls access to the real object
  • Can add functionality before/after delegating to real object
  • Appears identical to clients

Key insight: Clients don’t know they’re talking to a proxy. They think they’re using the real object.

Types of Proxies

Virtual Proxy

Purpose: Delay expensive object creation until actually needed.

Real-world case: Image loading in content management system.

You’re building a CMS where articles contain images. Loading all images immediately would:

  • Consume excessive memory
  • Slow down page rendering
  • Waste bandwidth for images user never sees

Virtual proxy solution:

  • Create lightweight placeholder for each image
  • Load actual image only when user scrolls to it
  • Cache loaded images for reuse
  • Release memory for images scrolled past

Benefits:

  • Faster initial page load
  • Lower memory consumption
  • Better user experience
  • Reduced server load

Protection Proxy

Purpose: Control access based on permissions.

Real-world case: Document management system.

Your company has confidential documents. Different employees have different access levels:

  • Public documents: Everyone can read
  • Internal documents: Employees only
  • Confidential: Managers only
  • Secret: C-level executives only

Protection proxy solution:

  • Check user permissions before allowing access
  • Log all access attempts for audit
  • Deny access with clear error messages
  • Support role-based access control (RBAC)

Benefits:

  • Centralized security logic
  • Consistent access control
  • Audit trail for compliance
  • Easy to modify permissions

Remote Proxy

Purpose: Represent object in different address space.

Real-world case: Microservices communication.

Your e-commerce platform has separate services:

  • Product service (manages inventory)
  • Order service (processes orders)
  • Payment service (handles transactions)

Order service needs product information but shouldn’t directly access product database. Remote proxy solution:

  • Order service uses proxy that looks like local product repository
  • Proxy makes HTTP/gRPC calls to product service
  • Handles network errors and retries
  • Caches responses to reduce network calls

Benefits:

  • Services remain decoupled
  • Network complexity hidden from business logic
  • Easy to add caching and retry logic
  • Can switch between local and remote implementations

Caching Proxy

Purpose: Cache expensive operations.

Real-world case: Analytics dashboard.

Your dashboard shows business metrics:

  • Revenue by region (complex SQL query)
  • User growth (aggregates millions of records)
  • Product performance (joins multiple tables)

Queries take 5-10 seconds each. Users refresh frequently. Database gets hammered.

Caching proxy solution:

  • Proxy sits between dashboard and database
  • First request: Execute query, cache result
  • Subsequent requests: Return cached result
  • Invalidate cache after 5 minutes or on data update

Benefits:

  • Dashboard responds instantly
  • Database load reduced by 90%
  • Better user experience
  • Lower infrastructure costs

Real-World Implementation: Database Proxy

Context

You’re building a SaaS application. Multiple tenants share the same database. Each tenant’s data must be isolated. You need:

  • Automatic tenant filtering on all queries
  • Query logging for debugging
  • Connection pooling for performance
  • Automatic retry on transient failures

Without Proxy

Every database call in your codebase looks like:

// Scattered throughout codebase
func GetUser(db *sql.DB, tenantID, userID string) (*User, error) {
    // Tenant check - repeated everywhere
    if tenantID == "" {
        return nil, errors.New("tenant required")
    }
    
    // Logging - repeated everywhere
    log.Printf("Query: GetUser, Tenant: %s, User: %s", tenantID, userID)
    
    // Actual query
    row := db.QueryRow("SELECT * FROM users WHERE tenant_id = $1 AND id = $2", tenantID, userID)
    
    // Error handling - repeated everywhere
    var user User
    err := row.Scan(&user.ID, &user.Name, &user.Email)
    if err != nil {
        log.Printf("Error: %v", err)
        return nil, err
    }
    
    return &user, nil
}

Problems:

  • Tenant check duplicated in every function
  • Easy to forget tenant filtering (security risk!)
  • Logging code clutters business logic
  • Can’t add features without modifying all functions

With Proxy

Proxy handles cross-cutting concerns:

type DatabaseProxy struct {
    db       *sql.DB
    tenantID string
    logger   *log.Logger
}

func (p *DatabaseProxy) QueryRow(query string, args ...interface{}) *sql.Row {
    // Automatic tenant injection
    modifiedQuery := p.injectTenantFilter(query)
    
    // Logging
    p.logger.Printf("Query: %s, Tenant: %s", query, p.tenantID)
    
    // Delegate to real database
    return p.db.QueryRow(modifiedQuery, args...)
}

Now your business logic is clean:

func GetUser(db *DatabaseProxy, userID string) (*User, error) {
    // No tenant check needed - proxy handles it
    // No logging needed - proxy handles it
    row := db.QueryRow("SELECT * FROM users WHERE id = $1", userID)
    
    var user User
    err := row.Scan(&user.ID, &user.Name, &user.Email)
    return &user, err
}

Benefits:

  • Business logic focuses on business
  • Security enforced automatically
  • Easy to add features (caching, metrics, tracing)
  • Single place to modify database behavior

Real-World Implementation: API Client Proxy

Context

Your application integrates with Stripe payment API. Requirements:

  • Rate limiting (100 requests per minute)
  • Automatic retry on network errors
  • Caching for GET requests
  • Logging all API calls for debugging
  • Circuit breaker to prevent cascading failures

Without Proxy

Every API call needs all this logic:

func ChargeCustomer(client *stripe.Client, amount int) error {
    // Rate limiting check
    if !rateLimiter.Allow() {
        return errors.New("rate limit exceeded")
    }
    
    // Logging
    log.Printf("Charging customer: %d", amount)
    
    // Retry logic
    var err error
    for i := 0; i < 3; i++ {
        err = client.Charge(amount)
        if err == nil {
            break
        }
        time.Sleep(time.Second * time.Duration(i+1))
    }
    
    // Circuit breaker check
    if err != nil {
        circuitBreaker.RecordFailure()
    }
    
    return err
}

This logic is duplicated for every API method. Maintenance nightmare.

With Proxy

Proxy encapsulates all cross-cutting concerns:

type StripeProxy struct {
    client         *stripe.Client
    rateLimiter    *RateLimiter
    cache          *Cache
    circuitBreaker *CircuitBreaker
    logger         *log.Logger
}

func (p *StripeProxy) Charge(amount int) error {
    // Check circuit breaker
    if p.circuitBreaker.IsOpen() {
        return errors.New("circuit breaker open")
    }
    
    // Check rate limit
    if !p.rateLimiter.Allow() {
        return errors.New("rate limited")
    }
    
    // Log request
    p.logger.Printf("Stripe charge: %d", amount)
    
    // Retry with exponential backoff
    err := p.retryWithBackoff(func() error {
        return p.client.Charge(amount)
    })
    
    // Update circuit breaker
    if err != nil {
        p.circuitBreaker.RecordFailure()
    } else {
        p.circuitBreaker.RecordSuccess()
    }
    
    return err
}

Business code stays simple:

func ProcessPayment(proxy *StripeProxy, amount int) error {
    // All complexity handled by proxy
    return proxy.Charge(amount)
}

Benefits:

  • Reliability features in one place
  • Easy to test (mock the proxy)
  • Can swap implementations (test vs production)
  • Business logic remains clean

Real-World Implementation: Lazy Loading Proxy

Context

Your application loads user profiles. Each profile includes:

  • Basic info (name, email) - small, fast
  • Avatar image - large, slow to load
  • Activity history - huge, expensive query
  • Preferences - medium size

Most requests only need basic info. Loading everything wastes resources.

Solution

Lazy loading proxy loads data on demand:

type UserProxy struct {
    userID   string
    repo     *UserRepository
    
    // Cached data
    basicInfo     *BasicInfo
    avatar        *Image
    history       *ActivityHistory
    preferences   *Preferences
    
    // Load flags
    basicLoaded   bool
    avatarLoaded  bool
    historyLoaded bool
    prefsLoaded   bool
}

func (p *UserProxy) GetBasicInfo() *BasicInfo {
    if !p.basicLoaded {
        p.basicInfo = p.repo.LoadBasicInfo(p.userID)
        p.basicLoaded = true
    }
    return p.basicInfo
}

func (p *UserProxy) GetAvatar() *Image {
    if !p.avatarLoaded {
        p.avatar = p.repo.LoadAvatar(p.userID)
        p.avatarLoaded = true
    }
    return p.avatar
}

Usage patterns:

Pattern 1: List view (only basic info)

users := GetUsers() // Returns proxies
for _, user := range users {
    // Only basic info loaded
    fmt.Printf("%s (%s)\n", user.GetBasicInfo().Name, user.GetBasicInfo().Email)
}
// Avatar and history never loaded - saved time and memory

Pattern 2: Detail view (everything)

user := GetUser(userID) // Returns proxy
// Load on demand as needed
info := user.GetBasicInfo()
avatar := user.GetAvatar()
history := user.GetHistory()

Benefits:

  • Faster list views (90% faster)
  • Lower memory usage (70% reduction)
  • Reduced database load
  • Same interface for clients

When to Use Proxy Pattern

Use when:

Access control needed:

  • Authentication/authorization
  • Audit logging
  • Rate limiting

Resource management required:

  • Lazy loading
  • Connection pooling
  • Caching

Additional functionality without modification:

  • Logging
  • Metrics
  • Tracing

Remote access:

  • Microservices communication
  • API clients
  • Distributed systems

Don’t use when:

Simple direct access sufficient:

  • No access control needed
  • No performance concerns
  • No additional functionality required

Overhead not justified:

  • Very simple objects
  • Performance critical path
  • Complexity outweighs benefits

Proxy vs Decorator

Both add functionality, but different purposes:

Proxy:

  • Controls access to object
  • May not create real object immediately
  • Focuses on access control and resource management
  • Client may not know it’s using proxy

Decorator:

  • Adds functionality to object
  • Real object always exists
  • Focuses on extending behavior
  • Client explicitly wraps object

Example distinction:

Proxy: “Check if user has permission before allowing database access” Decorator: “Add encryption to file writer”

Common Pitfalls

Pitfall 1: Proxy becomes god object

Don’t add unrelated functionality to proxy. Keep it focused.

Bad: Proxy handles caching, logging, metrics, validation, transformation, and business logic.

Good: Separate proxies for separate concerns. Chain them if needed.

Pitfall 2: Forgetting to implement all interface methods

If proxy doesn’t implement complete interface, clients break.

Solution: Use interface embedding in Go to ensure completeness.

Pitfall 3: Performance overhead

Every proxy call adds overhead. Don’t proxy in hot paths unless necessary.

Solution: Profile first. Add proxy only where benefits outweigh costs.

Testing with Proxies

Proxies make testing easier:

Test proxy behavior:

func TestCachingProxy(t *testing.T) {
    mock := &MockDatabase{}
    proxy := NewCachingProxy(mock)
    
    // First call hits database
    proxy.GetUser("user-1")
    assert.Equal(t, 1, mock.CallCount)
    
    // Second call uses cache
    proxy.GetUser("user-1")
    assert.Equal(t, 1, mock.CallCount) // Still 1!
}

Test with mock proxy:

func TestBusinessLogic(t *testing.T) {
    mockProxy := &MockDatabaseProxy{
        Users: map[string]*User{
            "user-1": {ID: "user-1", Name: "Test"},
        },
    }
    
    // Test business logic without real database
    result := ProcessUser(mockProxy, "user-1")
    assert.NotNil(t, result)
}

Conclusion

Proxy pattern provides controlled access to objects.

Key benefits:

  • Separation of concerns
  • Centralized cross-cutting logic
  • Easy to add functionality
  • Transparent to clients

Common use cases:

  • Access control and security
  • Resource management and optimization
  • Remote object access
  • Lazy loading and caching

Implementation tips:

  • Keep proxies focused
  • Implement complete interface
  • Consider performance impact
  • Use for cross-cutting concerns

Proxy pattern is essential for building maintainable, secure, and performant Go applications.


How do you use proxies in your Go applications? Share your experience in comments or reach out directly.

comments powered by Disqus