Proxy Pattern #

Ada sebuah ReportService yang menghasilkan laporan keuangan — setiap kali dipanggil, ia membaca ribuan baris data dari database, melakukan agregasi kompleks, dan menghasilkan PDF besar. Prosesnya memakan waktu 8 detik. Tiga masalah muncul sekaligus: pertama, tidak semua user seharusnya bisa mengakses laporan ini — hanya role finance_admin. Kedua, laporan yang sama diminta berulang kali dalam satu sesi sehingga seharusnya bisa di-cache. Ketiga, setiap akses perlu di-log untuk keperluan audit regulasi. Mengubah ReportService langsung untuk menangani semua concern ini akan mencampur logika bisnis dengan logika infrastruktur. Proxy Pattern memberikan solusi yang lebih bersih: bungkus ReportService dalam satu atau beberapa proxy — masing-masing menangani satu concern — sementara ReportService tetap fokus pada tugasnya: menghasilkan laporan.

Apa itu Proxy Pattern? #

Proxy Pattern adalah structural design pattern yang menyediakan objek pengganti yang mengontrol akses ke objek asli. Proxy mengimplementasikan interface yang sama dengan objek yang diwakilnya, sehingga client tidak perlu tahu apakah ia sedang berbicara dengan objek asli atau proxy.

Perbedaan kunci Proxy dari pattern lain yang juga “membungkus” objek: Proxy berfokus pada kontrol akses dan manajemen lifecycle, bukan pada penambahan behavior fungsional. Decorator menambahkan fitur baru; Proxy mengontrol, melindungi, atau mengoptimalkan akses ke fitur yang sudah ada.

Lima alasan utama menggunakan Proxy:

  • Virtual Proxy — menunda pembuatan objek mahal sampai benar-benar dibutuhkan (lazy initialization)
  • Protection Proxy — mengontrol siapa yang boleh mengakses objek berdasarkan otorisasi
  • Caching Proxy — menyimpan hasil operasi mahal agar tidak diulang untuk input yang sama
  • Remote Proxy — merepresentasikan objek yang berada di sistem lain (network, microservice)
  • Logging/Monitoring Proxy — mencatat semua akses untuk audit, debugging, atau observability
flowchart LR
    C[Client] -->|interface yang sama| P[Proxy]
    P -->|setelah kontrol & validasi| R[Real Subject]

    subgraph "Yang Proxy Lakukan Sebelum/Sesudah"
        direction TB
        P1["✓ Cek otorisasi"]
        P2["✓ Periksa cache"]
        P3["✓ Log request"]
        P4["✓ Lazy-init real subject"]
        P5["✓ Rate limiting"]
    end

Lima Jenis Proxy dan Use Case-nya #

Setiap jenis proxy menyelesaikan masalah yang berbeda. Memahami keempatnya membantu kamu memilih jenis yang tepat untuk situasi yang dihadapi.

flowchart TD
    Q{"Masalah apa\nyang ingin\ndipecahkan?"}
    Q -->|"Objek mahal dibuat,\ntapi mungkin tidak dipakai"| VP[Virtual Proxy\nLazy initialization]
    Q -->|"Tidak semua user\nboleh akses"| PP[Protection Proxy\nOtorisasi & auth]
    Q -->|"Hasil komputasi\nmahal dan berulang"| CP[Caching Proxy\nMemoization]
    Q -->|"Objek ada di\nsistem lain"| RP[Remote Proxy\nNetwork abstraction]
    Q -->|"Perlu tahu siapa\nmengakses apa & kapan"| LP[Logging Proxy\nAudit & observability]

Virtual Proxy: Lazy Initialization #

Objek seperti koneksi database, image editor, atau PDF renderer mahal untuk dibuat. Jika ada kemungkinan objek tidak akan digunakan dalam sesi tersebut, menundanya sampai pertama kali dibutuhkan bisa menghemat resource secara signifikan.

// ANTI-PATTERN: selalu buat objek mahal saat startup, meski mungkin tidak dipakai
type App struct {
    reportGenerator *HeavyReportGenerator // ~3 detik untuk diinisialisasi
    pdfRenderer     *PDFRenderer           // ~2 detik untuk diinisialisasi
    // keduanya selalu dibuat, meski user mungkin tidak pernah generate report
}

// BENAR: Virtual Proxy — buat hanya saat pertama kali dibutuhkan
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 {
        // Inisialisasi dilakukan di sini, bukan di 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: Otorisasi #

Protection Proxy mengecek apakah pemanggil memiliki izin yang cukup sebelum mendelegasikan ke objek asli. Ini memisahkan logika otorisasi dari logika bisnis.

// Protection Proxy memastikan hanya role yang tepat yang bisa mengakses
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)
}

Implementasi Lengkap: Report Service dengan Stack Proxy #

Mari bangun ReportService yang dilindungi oleh tiga lapis proxy: otorisasi, caching, dan logging — masing-masing sebagai proxy terpisah yang bisa dikombinasikan.

Subject Interface #

package report

import (
    "context"
    "time"
)

// Report merepresentasikan laporan yang dihasilkan.
type Report struct {
    ID          string
    Title       string
    GeneratedAt time.Time
    DataPoints  int
    Content     []byte // isi PDF atau HTML
    CacheKey    string
}

// ReportParams mendefinisikan parameter untuk menghasilkan laporan.
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 adalah Subject interface — kontrak yang diimplementasikan
// oleh real service dan semua proxy-nya.
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 menyimpan metadata laporan tanpa isi penuh.
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 adalah real subject — menghasilkan laporan sesungguhnya.
// Tidak ada logging, caching, atau otorisasi di sini.
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) {
    // Simulasi proses mahal: query data, agregasi, 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 ke database untuk mengambil laporan yang sudah tersimpan
    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 mendefinisikan kontrak untuk pengecekan otorisasi.
type AuthorizationService interface {
    CheckPermission(ctx context.Context, userID, permission string) error
    GetUserRoles(ctx context.Context, userID string) ([]string, error)
}

// ProtectionProxy mengontrol akses berdasarkan otorisasi user.
// Selalu menjadi lapisan terluar — tidak ada gunanya caching atau logging
// jika user tidak punya akses.
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)

    // Cek permission umum untuk generate report
    if err := p.authSvc.CheckPermission(ctx, userID, "report:generate"); err != nil {
        return nil, fmt.Errorf("permission denied: %w", err)
    }

    // Cek permission spesifik per tipe laporan
    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) {
    // Untuk list, filter berdasarkan role user
    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 laporan yang boleh dilihat berdasarkan role
    return reports // simplified — implementasi nyata akan 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 menyimpan laporan yang di-cache beserta waktu kadaluarsanya.
type CacheEntry struct {
    report    *Report
    expiresAt time.Time
}

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

// CachingProxy menyimpan hasil Generate agar tidak diulang untuk parameter yang sama.
// Laporan yang sama (tipe + periode + format) tidak perlu di-generate dua kali.
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 cache — optimistic read dengan 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 laporan baru
    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
    }

    // Simpan ke 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 tidak di-cache karena report bisa diupdate
    return p.real.GetByID(ctx, reportID)
}

func (p *CachingProxy) ListAvailable(ctx context.Context, userID string) ([]ReportMeta, error) {
    // ListAvailable di-cache per user dengan TTL pendek
    cacheKey := fmt.Sprintf("list:%s", userID)

    p.mu.RLock()
    if entry, ok := p.cache[cacheKey]; ok && !entry.isExpired() {
        p.mu.RUnlock()
        // Simplified: dalam nyatanya CacheEntry perlu menyimpan []ReportMeta juga
        return nil, nil
    }
    p.mu.RUnlock()

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

// Invalidate menghapus entri cache tertentu.
// Dipanggil ketika data yang mempengaruhi laporan berubah.
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 mengembalikan statistik cache untuk 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 merepresentasikan satu kejadian akses untuk keperluan audit.
type AuditEvent struct {
    UserID     string
    Action     string
    ReportType string
    Success    bool
    Duration   time.Duration
    Error      string
    Timestamp  time.Time
}

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

// LoggingProxy mencatat semua akses ke ReportService untuk keperluan audit.
// Wajib ada di sistem yang tunduk pada regulasi (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 jika gagal
    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)
}

Perakitan: Stack Proxy #

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

    // Lapisan terdalam: real implementation
    real := NewReportGenerator(db, template)

    // Lapisan caching — langsung di atas real service
    cached := NewCachingProxy(real, cacheTTL)

    // Lapisan logging — di atas caching
    // (log akan menunjukkan apakah ini cache hit atau miss)
    logged := NewLoggingProxy(cached, logger, auditStore)

    // Lapisan otorisasi — paling luar
    // (tidak perlu cache, log, atau hit real service jika user tidak berhak)
    authorized := NewProtectionProxy(logged, authSvc)

    return authorized
}

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

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

    // Client hanya tahu interface ReportService — tidak tahu ada 3 proxy di baliknya
    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: Mengabstraksi Network #

Remote Proxy merepresentasikan objek yang ada di service lain — client berinteraksi seolah objek ada di lokal, padahal semua operasi melewati network.

// UserServiceClient adalah Remote Proxy untuk UserService yang ada di microservice lain.
// Client code menggunakan interface yang sama seolah service ada di lokal.
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)
    // ... implementasi serupa
    return nil, nil
}

// Client tidak tahu apakah UserService ini lokal atau remote
func processUser(svc UserService, id int) {
    user, err := svc.FindByID(context.Background(), id)
    // sama persis untuk lokal atau remote
}

Virtual Proxy dengan sync.Once #

Untuk lazy initialization yang thread-safe, kombinasi Proxy dengan sync.Once adalah idiom yang sangat umum di Golang.

// LazyPDFRenderer adalah Virtual Proxy untuk PDF renderer yang mahal.
// Renderer hanya dibuat saat pertama kali Generate dipanggil.
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() {
        // Inisialisasi mahal — hanya terjadi satu kali
        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 di Ekosistem Golang #

Proxy Pattern muncul secara natural di banyak bagian ekosistem Golang, meski jarang disebut secara eksplisit.

gRPC Unary Interceptor sebagai Proxy #

// gRPC interceptor adalah Proxy untuk semua 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: cek token sebelum forward ke 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 sebelum dan sesudah 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
    }
}

// Rangkai interceptor — sama seperti merangkai proxy
server := grpc.NewServer(
    grpc.ChainUnaryInterceptor(
        LoggingInterceptor(logger),  // outer: log dulu
        AuthInterceptor(authSvc),    // inner: cek auth
    ),
)

http.RoundTripper sebagai Proxy #

// RetryTransport adalah Proxy untuk http.RoundTripper dengan 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 // sukses atau client error — tidak perlu retry
        }
        if attempt < t.maxRetries {
            time.Sleep(t.retryDelay * time.Duration(attempt+1))
        }
    }
    return resp, err
}

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

Testing Proxy #

// MockReportService untuk testing proxy secara terisolasi
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),
    }

    // Panggil pertama — 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)
    }

    // Panggil kedua — harus cache hit, real service tidak dipanggil lagi
    _, 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: Kapan Mana #

Keduanya mengimplementasikan interface yang sama dan membungkus objek lain — perbedaannya ada di niat desain dan cara penggunaan.

Aspek Proxy Decorator
Niat utama Kontrol akses, lifecycle, representasi Tambah behavior fungsional
Dibuat oleh System/infrastructure — client tidak memilih Client — memilih decorator yang diinginkan
Jumlah lapisan Biasanya satu proxy per concern Bisa banyak layer bertingkat bebas
Tahu wrapped object? Proxy mengelola lifecycle wrapped object Decorator menerima wrapped object dari luar
Contoh Auth proxy, cache proxy, lazy proxy Logging decorator, retry decorator
// Proxy: sistem yang menentukan apakah objek asli dibuat atau tidak
type LazyProxy struct {
    real *HeavyService // proxy mengelola lifecycle ini
    once sync.Once
}
// Client tidak bisa bypass — proxy mengontrol akses ke real object

// Decorator: client memilih dan merangkai
type LoggingDecorator struct {
    next HeavyService // disuplai dari luar oleh client
}
// Client bisa pakai real object langsung tanpa decorator

Kapan Menggunakan dan Kapan Tidak #

GUNAKAN Proxy jika:
  ✓ Perlu lazy initialization — objek mahal yang mungkin tidak digunakan
  ✓ Perlu otorisasi sebelum mengizinkan akses ke objek
  ✓ Ingin caching hasil operasi mahal yang deterministik
  ✓ Perlu merepresentasikan objek di sistem remote (microservice, API)
  ✓ Perlu audit log untuk setiap akses tanpa mengubah objek asli

HINDARI Proxy jika:
  ✗ Hanya butuh menambah fitur fungsional — gunakan Decorator
  ✗ Satu proxy menangani terlalu banyak concern — pecah menjadi proxy terpisah
  ✗ Objek yang diproksikan sangat sederhana — overhead tidak sepadan
  ✗ Proxy berisi business logic — proxy seharusnya tipis dan fokus

Checklist Review Proxy #

DESAIN:
  □ Proxy mengimplementasikan interface yang sama persis dengan real subject
  □ Setiap proxy menangani satu concern (Single Responsibility)
  □ Urutan proxy dalam stack sudah dipertimbangkan: auth → log → cache → real
  □ Proxy tidak berisi business logic

IMPLEMENTASI:
  □ Caching proxy menggunakan mutex untuk akses concurrent yang aman
  □ Protection proxy selalu memeriksa izin sebelum mendelegasikan
  □ Logging proxy tidak menggagalkan operasi meski logging gagal
  □ Virtual proxy menggunakan sync.Once untuk thread-safe lazy initialization
  □ Semua proxy meneruskan context ke real subject

TESTING:
  □ Setiap proxy di-test secara terpisah dengan mock real subject
  □ Test cache hit dan cache miss untuk caching proxy
  □ Test akses ditolak dan diizinkan untuk protection proxy
  □ Test bahwa real subject tidak dipanggil ketika akses ditolak
  □ Test audit log dicatat meski operasi gagal (untuk logging proxy)

Ringkasan #

  • Proxy menyediakan pengganti yang mengontrol akses ke objek asli — client menggunakan interface yang sama, tidak tahu ada proxy di antara mereka.
  • Lima jenis proxy: Virtual (lazy init), Protection (otorisasi), Caching (memoization), Remote (network abstraction), dan Logging (audit/observability) — masing-masing untuk masalah yang berbeda.
  • Urutan proxy penting: Protection harus paling luar (tidak ada gunanya caching jika user tidak berhak), diikuti Logging, kemudian Caching, lalu Real Subject.
  • Bedakan dari Decorator: Proxy untuk kontrol dan manajemen lifecycle; Decorator untuk penambahan behavior fungsional. Niatnya berbeda meski struktur kodenya mirip.
  • gRPC interceptor dan http.RoundTripper adalah implementasi Proxy Pattern yang sudah built-in di Golang — kamu sudah menggunakannya tanpa menyadarinya.
  • Caching proxy harus thread-safe — gunakan sync.RWMutex dengan double-check untuk mencegah race condition.
  • Virtual proxy dengan sync.Once adalah kombinasi idiomatik Golang untuk lazy initialization yang aman dari race condition.
  • Proxy harus tipis — jika proxy mulai berisi business logic, itu sinyal bahwa logika tersebut seharusnya ada di real subject atau di service layer.

← Sebelumnya: Flyweight   Berikutnya: Strategy →

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