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:
- HTTP Clients: Adding retry logic, rate limiting, and caching to API calls
- Database Connections: Connection pooling, query caching, and access control
- File Systems: Lazy loading, caching, and access control for file operations
- Remote Services: Adding circuit breakers and fallback mechanisms
- Resource Management: Controlling access to expensive resources like memory or CPU
Benefits of Proxy Pattern
- Transparency: Clients use proxies the same way as real objects
- Lazy Loading: Expensive operations can be deferred until needed
- Access Control: Can add security and authorization layers
- Caching: Can add performance improvements through caching
- Separation of Concerns: Keeps additional functionality separate from core logic
Caveats
While the Proxy pattern is powerful, consider these limitations:
- Complexity: Adds additional layers that can complicate debugging
- Performance: May introduce overhead, especially with multiple proxy layers
- Memory Usage: Proxies consume additional memory
- Interface Coupling: Proxy must implement the same interface as the real object
- 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!