Proxy Pattern #

There is a ReportService that generates financial reports — every time it is called, it reads thousands of rows of data from the database, performs complex aggregation, and produces a large PDF. The process takes 8 seconds. Three problems appear at once: first, not every user should be able to access this report — only the finance_admin role. Second, the same report gets requested repeatedly within one session, so it should be cacheable. Third, every access needs to be logged for regulatory audit purposes. Modifying ReportService directly to handle all these concerns would mix business logic with infrastructure logic. The Proxy Pattern offers a cleaner solution: wrap ReportService in one or more proxies — each handling one concern — while ReportService stays focused on its job: generating reports.

What Is the Proxy Pattern? #

The Proxy Pattern is a structural design pattern that provides a substitute object that controls access to the original object. The proxy implements the same interface as the object it represents, so the client cannot tell whether it is talking to the original object or a proxy.

The key difference between Proxy and other patterns that also “wrap” objects: Proxy focuses on access control and lifecycle management, not on adding functional behavior. A Decorator adds new features; a Proxy controls, protects, or optimizes access to features that already exist.

Five main reasons to use a Proxy:

  • Virtual Proxy — defers creating an expensive object until it is actually needed (lazy initialization)
  • Protection Proxy — controls who may access an object based on authorization
  • Caching Proxy — stores the results of expensive operations so they are not repeated for the same input
  • Remote Proxy — represents an object that lives on another system (network, microservice)
  • Logging/Monitoring Proxy — records every access for audit, debugging, or observability
flowchart LR
    C[Client] -->|same interface| P[Proxy]
    P -->|after control & validation| R[Real Subject]

    subgraph "What the Proxy Does Before/After"
        direction TB
        P1["✓ Check authorization"]
        P2["✓ Check cache"]
        P3["✓ Log request"]
        P4["✓ Lazy-init the real subject"]
        P5["✓ Rate limiting"]
    end

Five Proxy Types and Their Use Cases #

Each proxy type solves a different problem. Understanding them helps you pick the right type for the situation you face.

flowchart TD
    Q{"What problem\\ndo you want\\nto solve?"}
    Q -->|"Expensive object created,\\nbut may never be used"| VP[Virtual Proxy\\nLazy initialization]
    Q -->|"Not all users\\nmay access"| PP[Protection Proxy\\nAuthorization & auth]
    Q -->|"Expensive computation\\nresults repeated"| CP[Caching Proxy\\nMemoization]
    Q -->|"Object lives on\\nanother system"| RP[Remote Proxy\\nNetwork abstraction]
    Q -->|"Need to know who\\naccesses what & when"| LP[Logging Proxy\\nAudit & observability]

Virtual Proxy: Lazy Initialization #

Objects like database connections, image editors, or PDF renderers are expensive to create. If there is a chance the object will not be used in the session, deferring it until first use can save significant resources.

// ANTI-PATTERN: always create expensive objects at startup, even if unused
type App struct {
    reportGenerator *HeavyReportGenerator // ~3 seconds to initialize
    pdfRenderer     *PDFRenderer           // ~2 seconds to initialize
    // both always created, even if the user never generates a report
}

// CORRECT: Virtual Proxy — create only when first needed
type LazyReportProxy struct {
    real   *HeavyReportGenerator
    mu     sync.Mutex
    config ReportConfig
}

func (p *LazyReportProxy) Generate(params ReportParams) (*Report, error) {
    p.mu.Lock()
    if p.real == nil {
        // Initialization happens here, not at startup
        var err error
        p.real, err = NewHeavyReportGenerator(p.config)
        if err != nil {
            p.mu.Unlock()
            return nil, fmt.Errorf("failed to initialize report generator: %w", err)
        }
    }
    p.mu.Unlock()
    return p.real.Generate(params)
}

Protection Proxy: Authorization #

A Protection Proxy checks whether the caller has sufficient permission before delegating to the original object. This separates authorization logic from business logic.

// Protection Proxy ensures only the right role can access
type AuthorizedReportProxy struct {
    real    ReportService
    authSvc AuthorizationService
}

func (p *AuthorizedReportProxy) Generate(ctx context.Context, params ReportParams) (*Report, error) {
    userID := getUserIDFromContext(ctx)
    if err := p.authSvc.CheckPermission(ctx, userID, "report:generate"); err != nil {
        return nil, fmt.Errorf("access denied: %w", err)
    }
    return p.real.Generate(ctx, params)
}
#

Full Implementation: Report Service with a Proxy Stack #

Let’s build a ReportService protected by three proxy layers: authorization, caching, and logging — each a separate proxy that can be combined.

Subject Interface #

package report

import (
    "context"
    "time"
)

// Report represents a generated report.
type Report struct {
    ID          string
    Title       string
    GeneratedAt time.Time
    DataPoints  int
    Content     []byte // PDF or HTML content
    CacheKey    string
}

// ReportParams defines the parameters for generating a report.
type ReportParams struct {
    Type      string    // "financial", "inventory", "user_activity"
    StartDate time.Time
    EndDate   time.Time
    Format    string    // "pdf", "xlsx", "html"
    Filters   map[string]string
}

// ReportService is the Subject interface — the contract implemented
// by the real service and all its proxies.
type ReportService interface {
    Generate(ctx context.Context, params ReportParams) (*Report, error)
    GetByID(ctx context.Context, reportID string) (*Report, error)
    ListAvailable(ctx context.Context, userID string) ([]ReportMeta, error)
}

// ReportMeta stores report metadata without the full content.
type ReportMeta struct {
    ID          string
    Title       string
    Type        string
    GeneratedAt time.Time
    SizeBytes   int64
}

Real Subject: ReportGenerator #

package report

import (
    "context"
    "database/sql"
    "fmt"
    "time"
)

// ReportGenerator is the real subject — it generates the actual reports.
// No logging, caching, or authorization here.
type ReportGenerator struct {
    db       *sql.DB
    template TemplateEngine
}

func NewReportGenerator(db *sql.DB, template TemplateEngine) ReportService {
    return &ReportGenerator{db: db, template: template}
}

func (g *ReportGenerator) Generate(ctx context.Context, params ReportParams) (*Report, error) {
    // Simulate an expensive process: query data, aggregate, render
    rows, err := g.db.QueryContext(ctx, buildQuery(params))
    if err != nil {
        return nil, fmt.Errorf("query failed: %w", err)
    }
    defer rows.Close()

    data, err := aggregateRows(rows)
    if err != nil {
        return nil, fmt.Errorf("aggregation failed: %w", err)
    }

    content, err := g.template.Render(params.Format, data)
    if err != nil {
        return nil, fmt.Errorf("render failed: %w", err)
    }

    return &Report{
        ID:          generateReportID(),
        Title:       fmt.Sprintf("%s Report (%s)", params.Type, params.StartDate.Format("2006-01")),
        GeneratedAt: time.Now(),
        DataPoints:  len(data),
        Content:     content,
        CacheKey:    buildCacheKey(params),
    }, nil
}

func (g *ReportGenerator) GetByID(ctx context.Context, reportID string) (*Report, error) {
    // Query the database to fetch a stored report
    return nil, nil // simplified
}

func (g *ReportGenerator) ListAvailable(ctx context.Context, userID string) ([]ReportMeta, error) {
    return nil, nil // simplified
}

func buildQuery(params ReportParams) string {
    return fmt.Sprintf("SELECT * FROM transactions WHERE date BETWEEN '%s' AND '%s'",
        params.StartDate.Format("2006-01-02"), params.EndDate.Format("2006-01-02"))
}

func buildCacheKey(params ReportParams) string {
    return fmt.Sprintf("%s:%s:%s:%s",
        params.Type,
        params.StartDate.Format("20060102"),
        params.EndDate.Format("20060102"),
        params.Format)
}

func generateReportID() string {
    return fmt.Sprintf("RPT-%d", time.Now().UnixNano())
}

Proxy 1: Protection Proxy #

package report

import (
    "context"
    "fmt"
)

// AuthorizationService defines the contract for authorization checks.
type AuthorizationService interface {
    CheckPermission(ctx context.Context, userID, permission string) error
    GetUserRoles(ctx context.Context, userID string) ([]string, error)
}

// ProtectionProxy controls access based on user authorization.
// Always the outermost layer — there is no point caching or logging
// if the user has no access.
type ProtectionProxy struct {
    real    ReportService
    authSvc AuthorizationService
}

func NewProtectionProxy(real ReportService, authSvc AuthorizationService) ReportService {
    return &ProtectionProxy{real: real, authSvc: authSvc}
}

func (p *ProtectionProxy) Generate(ctx context.Context, params ReportParams) (*Report, error) {
    userID := getUserIDFromContext(ctx)

    // Check the general permission for generating reports
    if err := p.authSvc.CheckPermission(ctx, userID, "report:generate"); err != nil {
        return nil, fmt.Errorf("permission denied: %w", err)
    }

    // Check the permission specific to this report type
    specificPerm := fmt.Sprintf("report:generate:%s", params.Type)
    if err := p.authSvc.CheckPermission(ctx, userID, specificPerm); err != nil {
        return nil, fmt.Errorf("permission denied for %s report: %w", params.Type, err)
    }

    return p.real.Generate(ctx, params)
}

func (p *ProtectionProxy) GetByID(ctx context.Context, reportID string) (*Report, error) {
    userID := getUserIDFromContext(ctx)
    if err := p.authSvc.CheckPermission(ctx, userID, "report:read"); err != nil {
        return nil, fmt.Errorf("permission denied: %w", err)
    }
    return p.real.GetByID(ctx, reportID)
}

func (p *ProtectionProxy) ListAvailable(ctx context.Context, userID string) ([]ReportMeta, error) {
    // For listings, filter based on the user's roles
    roles, err := p.authSvc.GetUserRoles(ctx, userID)
    if err != nil {
        return nil, fmt.Errorf("failed to get user roles: %w", err)
    }

    allReports, err := p.real.ListAvailable(ctx, userID)
    if err != nil {
        return nil, err
    }

    return filterByRoles(allReports, roles), nil
}

func filterByRoles(reports []ReportMeta, roles []string) []ReportMeta {
    // Filter which reports may be seen based on role
    return reports // simplified — a real implementation would filter
}

func getUserIDFromContext(ctx context.Context) string {
    if id, ok := ctx.Value("user_id").(string); ok {
        return id
    }
    return ""
}

Proxy 2: Caching Proxy #

package report

import (
    "context"
    "fmt"
    "sync"
    "time"
)

// CacheEntry stores a cached report along with its expiry time.
type CacheEntry struct {
    report    *Report
    expiresAt time.Time
}

func (e *CacheEntry) isExpired() bool {
    return time.Now().After(e.expiresAt)
}

// CachingProxy stores Generate results so they are not repeated for the same parameters.
// The same report (type + period + format) does not need to be generated twice.
type CachingProxy struct {
    real  ReportService
    mu    sync.RWMutex
    cache map[string]*CacheEntry
    ttl   time.Duration
    stats struct {
        hits   int64
        misses int64
    }
}

func NewCachingProxy(real ReportService, ttl time.Duration) ReportService {
    return &CachingProxy{
        real:  real,
        cache: make(map[string]*CacheEntry),
        ttl:   ttl,
    }
}

func (p *CachingProxy) Generate(ctx context.Context, params ReportParams) (*Report, error) {
    cacheKey := buildCacheKey(params)

    // Check the cache — optimistic read with RLock
    p.mu.RLock()
    if entry, ok := p.cache[cacheKey]; ok && !entry.isExpired() {
        p.mu.RUnlock()
        p.stats.hits++
        fmt.Printf("[CachingProxy] Cache HIT for key: %s\n", cacheKey)
        return entry.report, nil
    }
    p.mu.RUnlock()

    // Cache miss — generate a new report
    p.stats.misses++
    fmt.Printf("[CachingProxy] Cache MISS for key: %s — generating...\n", cacheKey)

    report, err := p.real.Generate(ctx, params)
    if err != nil {
        return nil, err
    }

    // Store in the cache
    p.mu.Lock()
    p.cache[cacheKey] = &CacheEntry{
        report:    report,
        expiresAt: time.Now().Add(p.ttl),
    }
    p.mu.Unlock()

    return report, nil
}

func (p *CachingProxy) GetByID(ctx context.Context, reportID string) (*Report, error) {
    // GetByID is not cached because a report can be updated
    return p.real.GetByID(ctx, reportID)
}

func (p *CachingProxy) ListAvailable(ctx context.Context, userID string) ([]ReportMeta, error) {
    // ListAvailable is cached per user with a short TTL
    cacheKey := fmt.Sprintf("list:%s", userID)

    p.mu.RLock()
    if entry, ok := p.cache[cacheKey]; ok && !entry.isExpired() {
        p.mu.RUnlock()
        // Simplified: in reality CacheEntry would need to store []ReportMeta too
        return nil, nil
    }
    p.mu.RUnlock()

    return p.real.ListAvailable(ctx, userID)
}

// Invalidate removes specific cache entries.
// Called when data affecting the reports changes.
func (p *CachingProxy) Invalidate(pattern string) {
    p.mu.Lock()
    defer p.mu.Unlock()
    for key := range p.cache {
        if matchesPattern(key, pattern) {
            delete(p.cache, key)
        }
    }
}

// CacheStats returns cache statistics for monitoring.
func (p *CachingProxy) CacheStats() (hits, misses int64, size int) {
    p.mu.RLock()
    defer p.mu.RUnlock()
    return p.stats.hits, p.stats.misses, len(p.cache)
}

func matchesPattern(key, pattern string) bool {
    return len(pattern) == 0 || key[:min(len(pattern), len(key))] == pattern
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}

Proxy 3: Logging Proxy #

package report

import (
    "context"
    "fmt"
    "log/slog"
    "time"
)

// AuditEvent represents a single access event for auditing purposes.
type AuditEvent struct {
    UserID     string
    Action     string
    ReportType string
    Success    bool
    Duration   time.Duration
    Error      string
    Timestamp  time.Time
}

// AuditStore stores audit events.
type AuditStore interface {
    Record(event AuditEvent) error
}

// LoggingProxy records every access to the ReportService for auditing.
// A must-have in systems subject to regulations (GDPR, SOX, PCI-DSS).
type LoggingProxy struct {
    real       ReportService
    logger     *slog.Logger
    auditStore AuditStore
}

func NewLoggingProxy(real ReportService, logger *slog.Logger, audit AuditStore) ReportService {
    return &LoggingProxy{real: real, logger: logger, auditStore: audit}
}

func (p *LoggingProxy) Generate(ctx context.Context, params ReportParams) (*Report, error) {
    userID := getUserIDFromContext(ctx)
    start := time.Now()

    p.logger.InfoContext(ctx, "report generation requested",
        "user_id", userID,
        "report_type", params.Type,
        "period", fmt.Sprintf("%s to %s",
            params.StartDate.Format("2006-01-02"),
            params.EndDate.Format("2006-01-02")),
        "format", params.Format,
    )

    report, err := p.real.Generate(ctx, params)
    duration := time.Since(start)

    event := AuditEvent{
        UserID:     userID,
        Action:     "report:generate",
        ReportType: params.Type,
        Success:    err == nil,
        Duration:   duration,
        Timestamp:  time.Now(),
    }

    if err != nil {
        event.Error = err.Error()
        p.logger.ErrorContext(ctx, "report generation failed",
            "user_id", userID,
            "report_type", params.Type,
            "duration_ms", duration.Milliseconds(),
            "error", err,
        )
    } else {
        p.logger.InfoContext(ctx, "report generation succeeded",
            "user_id", userID,
            "report_id", report.ID,
            "report_type", params.Type,
            "data_points", report.DataPoints,
            "duration_ms", duration.Milliseconds(),
        )
    }

    // Audit log — non-fatal if it fails
    if auditErr := p.auditStore.Record(event); auditErr != nil {
        p.logger.WarnContext(ctx, "failed to record audit event", "error", auditErr)
    }

    return report, err
}

func (p *LoggingProxy) GetByID(ctx context.Context, reportID string) (*Report, error) {
    userID := getUserIDFromContext(ctx)
    p.logger.InfoContext(ctx, "report access", "user_id", userID, "report_id", reportID)

    report, err := p.real.GetByID(ctx, reportID)

    _ = p.auditStore.Record(AuditEvent{
        UserID:    userID,
        Action:    "report:read",
        Success:   err == nil,
        Timestamp: time.Now(),
    })

    return report, err
}

func (p *LoggingProxy) ListAvailable(ctx context.Context, userID string) ([]ReportMeta, error) {
    return p.real.ListAvailable(ctx, userID)
}

Assembly: The Proxy Stack #

func NewReportServiceStack(
    db *sql.DB,
    template TemplateEngine,
    authSvc AuthorizationService,
    logger *slog.Logger,
    auditStore AuditStore,
    cacheTTL time.Duration,
) ReportService {

    // Innermost layer: the real implementation
    real := NewReportGenerator(db, template)

    // Caching layer — directly on top of the real service
    cached := NewCachingProxy(real, cacheTTL)

    // Logging layer — on top of caching
    // (logs will show whether this was a cache hit or miss)
    logged := NewLoggingProxy(cached, logger, auditStore)

    // Authorization layer — outermost
    // (no point caching, logging, or hitting the real service if the user is not allowed)
    authorized := NewProtectionProxy(logged, authSvc)

    return authorized
}

// Usage
func main() {
    svc := NewReportServiceStack(db, tmpl, authSvc, logger, audit, 30*time.Minute)

    ctx := context.WithValue(context.Background(), "user_id", "user-123")

    // The client only knows the ReportService interface — it has no idea there are 3 proxies behind it
    report, err := svc.Generate(ctx, ReportParams{
        Type:      "financial",
        StartDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
        EndDate:   time.Date(2024, 3, 31, 0, 0, 0, 0, time.UTC),
        Format:    "pdf",
    })
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Report generated: %s\n", report.ID)
}
#

Remote Proxy: Abstracting the Network #

A Remote Proxy represents an object that lives in another service — the client interacts as if the object were local, while all operations actually go over the network.

// UserServiceClient is a Remote Proxy for a UserService in another microservice.
// Client code uses the same interface as if the service were local.
type UserServiceClient struct {
    baseURL    string
    httpClient *http.Client
    timeout    time.Duration
}

func NewUserServiceClient(baseURL string, timeout time.Duration) UserService {
    return &UserServiceClient{
        baseURL:    baseURL,
        httpClient: &http.Client{Timeout: timeout},
        timeout:    timeout,
    }
}

func (c *UserServiceClient) FindByID(ctx context.Context, id int) (*User, error) {
    url := fmt.Sprintf("%s/users/%d", c.baseURL, id)
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("failed to build request: %w", err)
    }

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("remote call failed: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode == http.StatusNotFound {
        return nil, fmt.Errorf("user %d not found", id)
    }
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
    }

    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, fmt.Errorf("failed to decode response: %w", err)
    }
    return &user, nil
}

func (c *UserServiceClient) FindByEmail(ctx context.Context, email string) (*User, error) {
    url := fmt.Sprintf("%s/users?email=%s", c.baseURL, email)
    // ... similar implementation
    return nil, nil
}

// The client has no idea whether this UserService is local or remote
func processUser(svc UserService, id int) {
    user, err := svc.FindByID(context.Background(), id)
    // exactly the same for local or remote
}
#

Virtual Proxy with sync.Once #

For thread-safe lazy initialization, combining Proxy with sync.Once is a very common Go idiom.

// LazyPDFRenderer is a Virtual Proxy for an expensive PDF renderer.
// The renderer is only created the first time Generate is called.
type LazyPDFRenderer struct {
    once     sync.Once
    renderer PDFRenderer
    config   PDFConfig
    initErr  error
}

func NewLazyPDFRenderer(config PDFConfig) PDFRenderer {
    return &LazyPDFRenderer{config: config}
}

func (l *LazyPDFRenderer) Generate(content []byte, options RenderOptions) ([]byte, error) {
    l.once.Do(func() {
        // Expensive initialization — happens only once
        l.renderer, l.initErr = initializePDFEngine(l.config)
        if l.initErr != nil {
            return
        }
        l.renderer.LoadFonts(l.config.FontPaths)
        l.renderer.SetQuality(l.config.Quality)
    })

    if l.initErr != nil {
        return nil, fmt.Errorf("PDF engine initialization failed: %w", l.initErr)
    }

    return l.renderer.Generate(content, options)
}

func (l *LazyPDFRenderer) Close() error {
    if l.renderer != nil {
        return l.renderer.Close()
    }
    return nil
}
#

Proxy in the Go Ecosystem #

The Proxy Pattern appears naturally in many parts of the Go ecosystem, though it is rarely called by name.

gRPC Unary Interceptor as a Proxy #

// A gRPC interceptor is a Proxy for every RPC call
func AuthInterceptor(authSvc AuthService) grpc.UnaryServerInterceptor {
    return func(
        ctx context.Context,
        req interface{},
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (interface{}, error) {
        // Protection Proxy: validate the token before forwarding to the handler
        token := extractToken(ctx)
        if err := authSvc.ValidateToken(ctx, token); err != nil {
            return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err)
        }
        return handler(ctx, req)
    }
}

func LoggingInterceptor(logger *slog.Logger) grpc.UnaryServerInterceptor {
    return func(
        ctx context.Context,
        req interface{},
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (interface{}, error) {
        // Logging Proxy: log before and after the handler
        start := time.Now()
        resp, err := handler(ctx, req)
        logger.InfoContext(ctx, "gRPC call",
            "method", info.FullMethod,
            "duration_ms", time.Since(start).Milliseconds(),
            "error", err,
        )
        return resp, err
    }
}

// Chain the interceptors — just like chaining proxies
server := grpc.NewServer(
    grpc.ChainUnaryInterceptor(
        LoggingInterceptor(logger),  // outer: log first
        AuthInterceptor(authSvc),    // inner: check auth
    ),
)

http.RoundTripper as a Proxy #

// RetryTransport is a Proxy for http.RoundTripper with retry logic
type RetryTransport struct {
    wrapped    http.RoundTripper
    maxRetries int
    retryDelay time.Duration
}

func NewRetryTransport(wrapped http.RoundTripper, maxRetries int) *RetryTransport {
    return &RetryTransport{
        wrapped:    wrapped,
        maxRetries: maxRetries,
        retryDelay: 500 * time.Millisecond,
    }
}

func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    var (
        resp *http.Response
        err  error
    )
    for attempt := 0; attempt <= t.maxRetries; attempt++ {
        resp, err = t.wrapped.RoundTrip(req)
        if err == nil && resp.StatusCode < 500 {
            return resp, nil // success or client error — no retry needed
        }
        if attempt < t.maxRetries {
            time.Sleep(t.retryDelay * time.Duration(attempt+1))
        }
    }
    return resp, err
}

// Usage
client := &http.Client{
    Transport: NewRetryTransport(http.DefaultTransport, 3),
}
#

Testing a Proxy #

// MockReportService for testing proxies in isolation
type MockReportService struct {
    GenerateFn     func(ctx context.Context, params ReportParams) (*Report, error)
    GetByIDFn      func(ctx context.Context, id string) (*Report, error)
    GenerateCount  int
}

func (m *MockReportService) Generate(ctx context.Context, params ReportParams) (*Report, error) {
    m.GenerateCount++
    if m.GenerateFn != nil {
        return m.GenerateFn(ctx, params)
    }
    return &Report{ID: "mock-001", Title: "Mock Report", CacheKey: buildCacheKey(params)}, nil
}

func (m *MockReportService) GetByID(ctx context.Context, id string) (*Report, error) {
    if m.GetByIDFn != nil {
        return m.GetByIDFn(ctx, id)
    }
    return &Report{ID: id}, nil
}

func (m *MockReportService) ListAvailable(ctx context.Context, userID string) ([]ReportMeta, error) {
    return nil, nil
}


func TestCachingProxy_DoesNotCallRealServiceOnCacheHit(t *testing.T) {
    mock := &MockReportService{}
    proxy := NewCachingProxy(mock, 5*time.Minute)
    ctx := context.Background()
    params := ReportParams{Type: "financial", Format: "pdf",
        StartDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
        EndDate:   time.Date(2024, 3, 31, 0, 0, 0, 0, time.UTC),
    }

    // First call — cache miss
    _, err := proxy.Generate(ctx, params)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if mock.GenerateCount != 1 {
        t.Errorf("expected 1 call on first request, got %d", mock.GenerateCount)
    }

    // Second call — must be a cache hit, the real service is not called again
    _, err = proxy.Generate(ctx, params)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if mock.GenerateCount != 1 {
        t.Errorf("expected real service not called on cache hit, got %d calls", mock.GenerateCount)
    }
}

func TestProtectionProxy_DeniesUnauthorizedAccess(t *testing.T) {
    mock := &MockReportService{}
    mockAuth := &MockAuthService{
        CheckPermissionFn: func(ctx context.Context, userID, perm string) error {
            return fmt.Errorf("permission denied")
        },
    }
    proxy := NewProtectionProxy(mock, mockAuth)

    ctx := context.WithValue(context.Background(), "user_id", "unauthorized-user")
    _, err := proxy.Generate(ctx, ReportParams{Type: "financial"})

    if err == nil {
        t.Error("expected error for unauthorized access, got nil")
    }
    if mock.GenerateCount != 0 {
        t.Errorf("real service should not be called when access is denied, got %d calls", mock.GenerateCount)
    }
}

func TestLoggingProxy_LogsOnSuccess(t *testing.T) {
    mock := &MockReportService{}
    var logBuf bytes.Buffer
    logger := slog.New(slog.NewTextHandler(&logBuf, nil))
    auditStore := &MockAuditStore{}

    proxy := NewLoggingProxy(mock, logger, auditStore)
    ctx := context.WithValue(context.Background(), "user_id", "user-123")

    _, _ = proxy.Generate(ctx, ReportParams{Type: "financial", Format: "pdf"})

    if !strings.Contains(logBuf.String(), "report generation succeeded") {
        t.Errorf("expected success log, got: %s", logBuf.String())
    }
    if len(auditStore.Events) != 1 {
        t.Errorf("expected 1 audit event, got %d", len(auditStore.Events))
    }
}
#

Proxy vs Decorator: When to Use Which #

Both implement the same interface and wrap another object — the difference lies in design intent and how they are used.

AspectProxyDecorator
Main intentAccess control, lifecycle, representationAdd functional behavior
Created bySystem/infrastructure — the client does not chooseClient — picks the decorators it wants
Number of layersUsually one proxy per concernCan have many freely stacked layers
Knows the wrapped object?Proxy manages the wrapped object’s lifecycleDecorator receives the wrapped object from outside
ExamplesAuth proxy, cache proxy, lazy proxyLogging decorator, retry decorator

// Proxy: the system decides whether the real object is created or not
type LazyProxy struct {
    real *HeavyService // the proxy manages this lifecycle
    once sync.Once
}
// The client cannot bypass — the proxy controls access to the real object

// Decorator: the client chooses and chains
type LoggingDecorator struct {
    next HeavyService // supplied from outside by the client
}
// The client can use the real object directly without the decorator
#

When to Use and When Not to #

USE Proxy if:
  ✓ You need lazy initialization — expensive objects that may never be used
  ✓ You need authorization before allowing access to an object
  ✓ You want to cache the results of expensive deterministic operations
  ✓ You need to represent an object on a remote system (microservice, API)
  ✓ You need an audit log for every access without changing the original object

AVOID Proxy if:
  ✗ You only need to add functional features — use a Decorator
  ✗ One proxy handles too many concerns — split it into separate proxies
  ✗ The proxied object is very simple — the overhead is not worth it
  ✗ The proxy contains business logic — a proxy should be thin and focused

Proxy Review Checklist #

DESIGN:
  □ The proxy implements the exact same interface as the real subject
  □ Each proxy handles one concern (Single Responsibility)
  □ The proxy order in the stack is considered: auth → log → cache → real
  □ The proxy contains no business logic

IMPLEMENTATION:
  □ The caching proxy uses a mutex for safe concurrent access
  □ The protection proxy always checks permission before delegating
  □ The logging proxy never fails an operation even if logging fails
  □ The virtual proxy uses sync.Once for thread-safe lazy initialization
  □ All proxies propagate the context to the real subject

TESTING:
  □ Each proxy is tested separately with a mocked real subject
  □ Cache hit and cache miss are tested for the caching proxy
  □ Denied and allowed access are tested for the protection proxy
  □ Test that the real subject is not called when access is denied
  □ Test that the audit log is recorded even when the operation fails (for the logging proxy)

Summary #

  • A Proxy provides a substitute that controls access to the original object — the client uses the same interface and does not know a proxy sits in between.
  • Five proxy types: Virtual (lazy init), Protection (authorization), Caching (memoization), Remote (network abstraction), and Logging (audit/observability) — each for a different problem.
  • Proxy order matters: Protection must be outermost (no point caching if the user is not allowed), then Logging, then Caching, then the Real Subject.
  • Distinguish it from Decorator: Proxy is for control and lifecycle management; Decorator is for adding functional behavior. The intent differs even though the code structure is similar.
  • gRPC interceptors and http.RoundTripper are built-in Proxy Pattern implementations in Go — you have been using them without realizing it.
  • A caching proxy must be thread-safe — use sync.RWMutex with double-check to prevent race conditions.
  • Virtual proxy with sync.Once is the idiomatic Go combination for race-safe lazy initialization.
  • A proxy must be thin — if the proxy starts containing business logic, that is a signal the logic belongs in the real subject or in the service layer.

← Previous: Flyweight   Next: Strategy →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact