📚 Go Design Patterns 🎯Behavioral Pattern

What is Chain of Responsibility?

The Chain of Responsibility pattern is a behavioral design pattern that allows you to pass requests along a chain of handlers. Each handler decides whether to process the request or pass it to the next handler in the chain. This pattern decouples the sender of a request from its receivers by giving multiple objects a chance to handle the request.

Think of it like a support ticket system - your request goes through Level 1 support first, then Level 2, then Level 3, until someone can handle it. Or like middleware in web frameworks - each middleware can process the request and decide whether to pass it to the next one.

Example 1: HTTP Middleware with Context

Let’s build a realistic HTTP middleware system that you’d actually use in production. We’ll use context.Context for proper cancellation and timeout handling.

Handler Interface with Context

package main

import (
    "context"
    "fmt"
    "net/http"
    "sync"
    "time"
)

// Middleware represents a handler in the chain
type Middleware func(http.Handler) http.Handler

// Request context keys
type contextKey string

const (
    UserContextKey  contextKey = "user"
    RolesContextKey contextKey = "roles"
)

// User represents an authenticated user
type User struct {
    ID       string
    Username string
    Email    string
    Roles    []string
}

Concrete Middleware Handlers

// AuthenticationMiddleware validates JWT tokens and adds user to context
func AuthenticationMiddleware() Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            token := r.Header.Get("Authorization")

            if token == "" {
                http.Error(w, "Missing authorization token", http.StatusUnauthorized)
                return
            }

            // Simulate token validation (in production, use JWT library)
            user, err := validateToken(token)
            if err != nil {
                http.Error(w, "Invalid token", http.StatusUnauthorized)
                return
            }

            // Add user to context
            ctx := context.WithValue(r.Context(), UserContextKey, user)
            fmt.Printf("[Auth] User '%s' authenticated\n", user.Username)

            // Pass to next handler with updated context
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

// AuthorizationMiddleware checks user roles
func AuthorizationMiddleware(requiredRoles ...string) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            user, ok := r.Context().Value(UserContextKey).(*User)
            if !ok {
                http.Error(w, "User not found in context", http.StatusUnauthorized)
                return
            }

            // Check if user has required roles
            if !hasRequiredRoles(user.Roles, requiredRoles) {
                http.Error(w, "Insufficient permissions", http.StatusForbidden)
                return
            }

            fmt.Printf("[AuthZ] User '%s' authorized with roles: %v\n",
                user.Username, user.Roles)
            next.ServeHTTP(w, r)
        })
    }
}

// RateLimitMiddleware implements token bucket rate limiting
func RateLimitMiddleware(requestsPerSecond int) Middleware {
    type bucket struct {
        tokens     int
        lastRefill time.Time
        mu         sync.Mutex
    }

    buckets := make(map[string]*bucket)
    var bucketsLock sync.RWMutex

    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            user, ok := r.Context().Value(UserContextKey).(*User)
            if !ok {
                http.Error(w, "User not found", http.StatusUnauthorized)
                return
            }

            bucketsLock.Lock()
            b, exists := buckets[user.ID]
            if !exists {
                b = &bucket{
                    tokens:     requestsPerSecond,
                    lastRefill: time.Now(),
                }
                buckets[user.ID] = b
            }
            bucketsLock.Unlock()

            b.mu.Lock()
            defer b.mu.Unlock()

            // Refill tokens based on time elapsed
            now := time.Now()
            elapsed := now.Sub(b.lastRefill)
            tokensToAdd := int(elapsed.Seconds()) * requestsPerSecond
            if tokensToAdd > 0 {
                b.tokens = min(requestsPerSecond, b.tokens+tokensToAdd)
                b.lastRefill = now
            }

            if b.tokens <= 0 {
                w.Header().Set("X-RateLimit-Remaining", "0")
                http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
                return
            }

            b.tokens--
            w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", b.tokens))
            fmt.Printf("[RateLimit] User '%s' - tokens remaining: %d\n",
                user.Username, b.tokens)

            next.ServeHTTP(w, r)
        })
    }
}

// LoggingMiddleware logs request details
func LoggingMiddleware() Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()

            // Create a response writer wrapper to capture status code
            wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}

            fmt.Printf("[Request] %s %s from %s\n",
                r.Method, r.URL.Path, r.RemoteAddr)

            next.ServeHTTP(wrapped, r)

            duration := time.Since(start)
            fmt.Printf("[Response] %d %s %s (took %v)\n",
                wrapped.statusCode, r.Method, r.URL.Path, duration)
        })
    }
}

// TimeoutMiddleware adds request timeout
func TimeoutMiddleware(timeout time.Duration) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), timeout)
            defer cancel()

            done := make(chan struct{})
            go func() {
                next.ServeHTTP(w, r.WithContext(ctx))
                close(done)
            }()

            select {
            case <-done:
                return
            case <-ctx.Done():
                http.Error(w, "Request timeout", http.StatusRequestTimeout)
                fmt.Printf("[Timeout] Request to %s timed out after %v\n",
                    r.URL.Path, timeout)
            }
        })
    }
}

// Helper types and functions
type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

func validateToken(token string) (*User, error) {
    // Simulate token validation
    if token == "Bearer valid-token" {
        return &User{
            ID:       "user-123",
            Username: "john_doe",
            Email:    "[email protected]",
            Roles:    []string{"user"},
        }, nil
    } else if token == "Bearer admin-token" {
        return &User{
            ID:       "admin-456",
            Username: "admin",
            Email:    "[email protected]",
            Roles:    []string{"user", "admin"},
        }, nil
    }
    return nil, fmt.Errorf("invalid token")
}

func hasRequiredRoles(userRoles, requiredRoles []string) bool {
    if len(requiredRoles) == 0 {
        return true
    }
    roleMap := make(map[string]bool)
    for _, role := range userRoles {
        roleMap[role] = true
    }
    for _, required := range requiredRoles {
        if !roleMap[required] {
            return false
        }
    }
    return true
}

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

Building the Middleware Chain

// Chain combines multiple middleware into one
func Chain(middlewares ...Middleware) Middleware {
    return func(final http.Handler) http.Handler {
        for i := len(middlewares) - 1; i >= 0; i-- {
            final = middlewares[i](final)
        }
        return final
    }
}

func main() {
    // Define your route handlers
    usersHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        user := r.Context().Value(UserContextKey).(*User)
        fmt.Fprintf(w, "Hello, %s! You accessed: %s\n", user.Username, r.URL.Path)
    })

    adminHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        user := r.Context().Value(UserContextKey).(*User)
        fmt.Fprintf(w, "Admin panel - Welcome %s\n", user.Username)
    })

    // Create middleware chains for different routes
    publicChain := Chain(
        LoggingMiddleware(),
        TimeoutMiddleware(5*time.Second),
    )

    userChain := Chain(
        LoggingMiddleware(),
        AuthenticationMiddleware(),
        RateLimitMiddleware(10), // 10 requests per second
        TimeoutMiddleware(5*time.Second),
    )

    adminChain := Chain(
        LoggingMiddleware(),
        AuthenticationMiddleware(),
        AuthorizationMiddleware("admin"),
        RateLimitMiddleware(20), // admins get higher limits
        TimeoutMiddleware(10*time.Second),
    )

    // Register routes with their respective chains
    mux := http.NewServeMux()
    mux.Handle("/", publicChain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Welcome to the public page!\n")
    })))
    mux.Handle("/api/users", userChain(usersHandler))
    mux.Handle("/api/admin", adminChain(adminHandler))

    fmt.Println("Server starting on :8080")
    http.ListenAndServe(":8080", mux)
}

Testing the Middleware Chain

# Public endpoint - no auth required
curl http://localhost:8080/

# User endpoint - requires authentication
curl -H "Authorization: Bearer valid-token" http://localhost:8080/api/users

# Admin endpoint - requires admin role
curl -H "Authorization: Bearer admin-token" http://localhost:8080/api/admin

# This will fail - user token on admin endpoint
curl -H "Authorization: Bearer valid-token" http://localhost:8080/api/admin

Example 2: Payment Processing Pipeline

Here’s another practical example - a payment processing system with fraud detection, balance validation, and transaction execution.

package main

import (
    "context"
    "fmt"
    "math/rand"
    "time"
)

// PaymentRequest represents a payment transaction
type PaymentRequest struct {
    ID            string
    UserID        string
    Amount        float64
    Currency      string
    PaymentMethod string
    IPAddress     string
    Timestamp     time.Time

    // Flags set by handlers
    FraudCheckPassed  bool
    BalanceVerified   bool
    TransactionID     string
    ProcessingFee     float64
}

// PaymentHandler processes payment requests
type PaymentHandler interface {
    SetNext(handler PaymentHandler) PaymentHandler
    Process(ctx context.Context, request *PaymentRequest) error
}

// BasePaymentHandler provides base functionality
type BasePaymentHandler struct {
    next PaymentHandler
}

func (h *BasePaymentHandler) SetNext(handler PaymentHandler) PaymentHandler {
    h.next = handler
    return handler
}

func (h *BasePaymentHandler) ProcessNext(ctx context.Context, request *PaymentRequest) error {
    if h.next != nil {
        return h.next.Process(ctx, request)
    }
    return nil
}

// FraudDetectionHandler checks for fraudulent transactions
type FraudDetectionHandler struct {
    BasePaymentHandler
    riskThreshold float64
}

func NewFraudDetectionHandler(riskThreshold float64) *FraudDetectionHandler {
    return &FraudDetectionHandler{riskThreshold: riskThreshold}
}

func (h *FraudDetectionHandler) Process(ctx context.Context, request *PaymentRequest) error {
    fmt.Printf("[FraudCheck] Analyzing transaction %s for user %s\n",
        request.ID, request.UserID)

    // Simulate fraud detection with multiple checks
    riskScore := h.calculateRiskScore(request)

    if riskScore > h.riskThreshold {
        return fmt.Errorf("transaction flagged as high risk (score: %.2f)", riskScore)
    }

    request.FraudCheckPassed = true
    fmt.Printf("[FraudCheck] Transaction approved (risk score: %.2f)\n", riskScore)

    return h.ProcessNext(ctx, request)
}

func (h *FraudDetectionHandler) calculateRiskScore(request *PaymentRequest) float64 {
    score := 0.0

    // Check amount (large amounts are riskier)
    if request.Amount > 10000 {
        score += 30.0
    } else if request.Amount > 5000 {
        score += 15.0
    }

    // Check time (night transactions are riskier)
    hour := request.Timestamp.Hour()
    if hour >= 22 || hour <= 5 {
        score += 20.0
    }

    // Simulate IP reputation check
    if rand.Float64() < 0.1 { // 10% chance of suspicious IP
        score += 40.0
    }

    return score
}

// BalanceCheckHandler verifies user has sufficient balance
type BalanceCheckHandler struct {
    BasePaymentHandler
    balanceService BalanceService
}

type BalanceService struct {
    balances map[string]float64
}

func NewBalanceService() *BalanceService {
    return &BalanceService{
        balances: map[string]float64{
            "user-123": 15000.0,
            "user-456": 500.0,
            "user-789": 50000.0,
        },
    }
}

func (s *BalanceService) GetBalance(userID string) float64 {
    return s.balances[userID]
}

func NewBalanceCheckHandler(service *BalanceService) *BalanceCheckHandler {
    return &BalanceCheckHandler{balanceService: *service}
}

func (h *BalanceCheckHandler) Process(ctx context.Context, request *PaymentRequest) error {
    fmt.Printf("[BalanceCheck] Verifying balance for user %s\n", request.UserID)

    balance := h.balanceService.GetBalance(request.UserID)
    totalAmount := request.Amount + request.ProcessingFee

    if balance < totalAmount {
        return fmt.Errorf("insufficient balance: have %.2f, need %.2f",
            balance, totalAmount)
    }

    request.BalanceVerified = true
    fmt.Printf("[BalanceCheck] Balance verified: %.2f available, %.2f required\n",
        balance, totalAmount)

    return h.ProcessNext(ctx, request)
}

// FeeCalculationHandler calculates processing fees
type FeeCalculationHandler struct {
    BasePaymentHandler
    feePercentage float64
}

func NewFeeCalculationHandler(feePercentage float64) *FeeCalculationHandler {
    return &FeeCalculationHandler{feePercentage: feePercentage}
}

func (h *FeeCalculationHandler) Process(ctx context.Context, request *PaymentRequest) error {
    fee := request.Amount * h.feePercentage

    // Minimum fee
    if fee < 0.50 {
        fee = 0.50
    }

    request.ProcessingFee = fee
    fmt.Printf("[FeeCalc] Processing fee calculated: $%.2f (%.1f%%)\n",
        fee, h.feePercentage*100)

    return h.ProcessNext(ctx, request)
}

// TransactionExecutionHandler executes the actual transaction
type TransactionExecutionHandler struct {
    BasePaymentHandler
}

func (h *TransactionExecutionHandler) Process(ctx context.Context, request *PaymentRequest) error {
    if !request.FraudCheckPassed || !request.BalanceVerified {
        return fmt.Errorf("cannot execute: transaction not validated")
    }

    fmt.Printf("[Execute] Processing payment of $%.2f + $%.2f fee\n",
        request.Amount, request.ProcessingFee)

    // Simulate payment processing delay
    select {
    case <-time.After(100 * time.Millisecond):
        // Transaction successful
        request.TransactionID = fmt.Sprintf("TXN-%d", time.Now().Unix())
        fmt.Printf("[Execute] Transaction successful! ID: %s\n", request.TransactionID)
    case <-ctx.Done():
        return fmt.Errorf("transaction cancelled: %v", ctx.Err())
    }

    return h.ProcessNext(ctx, request)
}

// NotificationHandler sends notifications
type NotificationHandler struct {
    BasePaymentHandler
}

func (h *NotificationHandler) Process(ctx context.Context, request *PaymentRequest) error {
    if request.TransactionID != "" {
        fmt.Printf("[Notify] Sending confirmation email for transaction %s\n",
            request.TransactionID)
        fmt.Printf("[Notify] Amount: $%.2f, Fee: $%.2f, Total: $%.2f\n",
            request.Amount, request.ProcessingFee,
            request.Amount+request.ProcessingFee)
    }

    return h.ProcessNext(ctx, request)
}

// Usage Example
func processPayment() {
    // Build the payment processing chain
    fraudCheck := NewFraudDetectionHandler(70.0)
    feeCalc := NewFeeCalculationHandler(0.029) // 2.9%
    balanceCheck := NewBalanceCheckHandler(NewBalanceService())
    execution := &TransactionExecutionHandler{}
    notification := &NotificationHandler{}

    // Chain them together
    fraudCheck.SetNext(feeCalc).
        SetNext(balanceCheck).
        SetNext(execution).
        SetNext(notification)

    // Test Case 1: Normal transaction
    fmt.Println("=== Test 1: Normal Transaction ===")
    payment1 := &PaymentRequest{
        ID:            "PAY-001",
        UserID:        "user-123",
        Amount:        1000.0,
        Currency:      "USD",
        PaymentMethod: "credit_card",
        IPAddress:     "192.168.1.1",
        Timestamp:     time.Now(),
    }

    ctx := context.Background()
    if err := fraudCheck.Process(ctx, payment1); err != nil {
        fmt.Printf("Payment failed: %v\n", err)
    }

    // Test Case 2: Insufficient balance
    fmt.Println("\n=== Test 2: Insufficient Balance ===")
    payment2 := &PaymentRequest{
        ID:            "PAY-002",
        UserID:        "user-456",
        Amount:        1000.0,
        Currency:      "USD",
        PaymentMethod: "credit_card",
        IPAddress:     "192.168.1.1",
        Timestamp:     time.Now(),
    }

    if err := fraudCheck.Process(ctx, payment2); err != nil {
        fmt.Printf("Payment failed: %v\n", err)
    }

    // Test Case 3: Large transaction (potential fraud)
    fmt.Println("\n=== Test 3: Large Transaction ===")
    payment3 := &PaymentRequest{
        ID:            "PAY-003",
        UserID:        "user-789",
        Amount:        15000.0,
        Currency:      "USD",
        PaymentMethod: "credit_card",
        IPAddress:     "192.168.1.1",
        Timestamp:     time.Date(2025, 1, 15, 2, 30, 0, 0, time.UTC), // 2:30 AM
    }

    if err := fraudCheck.Process(ctx, payment3); err != nil {
        fmt.Printf("Payment failed: %v\n", err)
    }
}

func main() {
    processPayment()
}

Output

=== Test 1: Normal Transaction ===
[FraudCheck] Analyzing transaction PAY-001 for user user-123
[FraudCheck] Transaction approved (risk score: 0.00)
[FeeCalc] Processing fee calculated: $29.00 (2.9%)
[BalanceCheck] Verifying balance for user user-123
[BalanceCheck] Balance verified: 15000.00 available, 1029.00 required
[Execute] Processing payment of $1000.00 + $29.00 fee
[Execute] Transaction successful! ID: TXN-1705334400
[Notify] Sending confirmation email for transaction TXN-1705334400
[Notify] Amount: $1000.00, Fee: $29.00, Total: $1029.00

=== Test 2: Insufficient Balance ===
[FraudCheck] Analyzing transaction PAY-002 for user user-456
[FraudCheck] Transaction approved (risk score: 0.00)
[FeeCalc] Processing fee calculated: $29.00 (2.9%)
[BalanceCheck] Verifying balance for user user-456
Payment failed: insufficient balance: have 500.00, need 1029.00

=== Test 3: Large Transaction ===
[FraudCheck] Analyzing transaction PAY-003 for user user-789
Payment failed: transaction flagged as high risk (score: 50.00)

Example 3: Dynamic Chain Building

Sometimes you need to build chains dynamically based on configuration:

type ChainBuilder struct {
    handlers []PaymentHandler
}

func NewChainBuilder() *ChainBuilder {
    return &ChainBuilder{handlers: make([]PaymentHandler, 0)}
}

func (b *ChainBuilder) AddHandler(handler PaymentHandler) *ChainBuilder {
    b.handlers = append(b.handlers, handler)
    return b
}

func (b *ChainBuilder) AddIf(condition bool, handler PaymentHandler) *ChainBuilder {
    if condition {
        b.handlers = append(b.handlers, handler)
    }
    return b
}

func (b *ChainBuilder) Build() PaymentHandler {
    if len(b.handlers) == 0 {
        return nil
    }

    for i := 0; i < len(b.handlers)-1; i++ {
        b.handlers[i].SetNext(b.handlers[i+1])
    }

    return b.handlers[0]
}

// Usage with configuration
func buildPaymentChain(config PaymentConfig) PaymentHandler {
    builder := NewChainBuilder()

    builder.AddHandler(NewFraudDetectionHandler(config.FraudThreshold))
    builder.AddHandler(NewFeeCalculationHandler(config.FeePercentage))

    // Conditionally add handlers based on config
    builder.AddIf(config.RequireBalanceCheck,
        NewBalanceCheckHandler(NewBalanceService()))
    builder.AddIf(config.EnableCurrencyConversion,
        NewCurrencyConversionHandler())

    builder.AddHandler(&TransactionExecutionHandler{})
    builder.AddIf(config.SendNotifications, &NotificationHandler{})

    return builder.Build()
}

type PaymentConfig struct {
    FraudThreshold           float64
    FeePercentage           float64
    RequireBalanceCheck     bool
    EnableCurrencyConversion bool
    SendNotifications       bool
}

// Placeholder for the example
type CurrencyConversionHandler struct {
    BasePaymentHandler
}

func NewCurrencyConversionHandler() *CurrencyConversionHandler {
    return &CurrencyConversionHandler{}
}

func (h *CurrencyConversionHandler) Process(ctx context.Context, request *PaymentRequest) error {
    fmt.Printf("[CurrencyConvert] Converting currency if needed\n")
    return h.ProcessNext(ctx, request)
}

Using with Chi Router

import "github.com/go-chi/chi/v5"

func setupRouter() *chi.Mux {
    r := chi.NewRouter()

    // Global middleware
    r.Use(LoggingMiddleware())
    r.Use(TimeoutMiddleware(30 * time.Second))

    // Public routes
    r.Group(func(r chi.Router) {
        r.Get("/", handleHome)
        r.Get("/health", handleHealth)
    })

    // Protected routes
    r.Group(func(r chi.Router) {
        r.Use(AuthenticationMiddleware())
        r.Use(RateLimitMiddleware(100))

        r.Get("/api/profile", handleProfile)
        r.Get("/api/orders", handleOrders)
    })

    // Admin routes
    r.Group(func(r chi.Router) {
        r.Use(AuthenticationMiddleware())
        r.Use(AuthorizationMiddleware("admin"))
        r.Use(RateLimitMiddleware(200))

        r.Get("/api/admin/users", handleAdminUsers)
        r.Delete("/api/admin/users/{id}", handleDeleteUser)
    })

    return r
}

Why use the Chain of Responsibility pattern?

From my experience building production systems in Go, here are the key benefits:

  1. Single Responsibility Principle - Each handler has one specific job. Authentication doesn’t know about rate limiting, and rate limiting doesn’t know about logging.

  2. Open/Closed Principle - You can add new handlers without modifying existing ones. Need CORS handling? Just add a new handler to the chain.

  3. Flexible Request Processing - You can dynamically change the order of handlers or add/remove handlers at runtime based on configuration.

  4. Testability - Each handler can be unit tested independently. You can test fraud detection without caring about payment execution.

  5. Reusable Components - Handlers can be reused across different chains. The same logging handler can be used in multiple request processing pipelines.

  6. Clear Error Handling - Any handler can stop the chain by returning an error, making error handling straightforward and predictable.

Real-world Use Cases

I’ve personally used the Chain of Responsibility pattern for:

  1. HTTP Middleware Chains - Authentication, authorization, CORS, rate limiting, logging, metrics collection
  2. Payment Processing - Fraud detection, balance checking, currency conversion, transaction execution, receipt generation
  3. Data Validation Pipelines - Schema validation, business rule validation, data sanitization, duplicate detection
  4. Log Processing - Filtering, formatting, enrichment, routing to different destinations (file, database, external service)
  5. Event Processing - Event validation, enrichment, transformation, routing to different consumers
  6. Email Processing - Spam detection, attachment scanning, content filtering, delivery
  7. Image Processing - Validation, resizing, watermarking, optimization, uploading to CDN

Caveats

While the Chain of Responsibility pattern is powerful, be aware of these potential issues:

  1. No Guarantee of Handling - A request might go through the entire chain without being handled. Make sure to have a default handler at the end if needed.

  2. Performance Overhead - Each handler adds a function call. For very high-performance scenarios with long chains, this might matter. Profile your code if performance is critical.

  3. Debugging Complexity - With many handlers, it can be harder to trace exactly where a request fails or gets modified. Good logging is essential.

  4. Order Matters - The sequence of handlers is crucial. Authentication must come before authorization, fraud detection before payment execution.

  5. Shared State - Be careful with handlers that maintain state (like rate limiters). Consider thread-safety if your handlers will be used concurrently. Use mutexes or sync primitives appropriately.

  6. Memory Usage - Each request carries context through the chain. Be mindful of what data you attach to the request/context object, especially in high-throughput systems.

Best Practices

  1. Use Context - Always use context.Context for cancellation, timeouts, and request-scoped values
  2. Fail Fast - If a handler fails, stop the chain immediately and return a clear error
  3. Log Extensively - Each handler should log what it’s doing for debugging
  4. Keep Handlers Small - Each handler should do one thing and do it well
  5. Make Chains Composable - Design handlers so they can be used in different combinations
  6. Test Each Handler - Unit test each handler independently with mock next handlers

Thank you

Thank you for reading! The Chain of Responsibility pattern is one of my favorite patterns in Go, especially for building clean, maintainable middleware systems and processing pipelines. I’d love to hear about how you’re using this pattern in your projects. Feel free to reach out at [email protected] with any feedback or questions!