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"]
endFive 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)
}
#
// 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)
}
#
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
}
#
// 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
}
#
// 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),
}
#
// 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))
}
}
#
// 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.
| Aspect | Proxy | Decorator |
|---|---|---|
| Main intent | Access control, lifecycle, representation | Add functional behavior |
| Created by | System/infrastructure — the client does not choose | Client — picks the decorators it wants |
| Number of layers | Usually one proxy per concern | Can have many freely stacked layers |
| Knows the wrapped object? | Proxy manages the wrapped object’s lifecycle | Decorator receives the wrapped object from outside |
| Examples | Auth proxy, cache proxy, lazy proxy | Logging 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
#
// 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.RWMutexwith double-check to prevent race conditions.- Virtual proxy with
sync.Onceis 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.