Chain of Responsibility Pattern #
An HTTP request arrives at a server. Before reaching the business handler, it must pass several checks: is there a valid JWT token? Has this user verified their email? Has the rate limit been exceeded? Does the request body exceed the size limit? Without Chain of Responsibility, all these checks end up in one large function that is hard to test and hard to reorder. With Chain of Responsibility, every check is a self-contained handler that can stand alone, and those handlers are chained in sequence — if one handler fails, it breaks the chain and returns an error; if it passes, the request moves to the next handler. Adding a new check means adding one handler and inserting it in the right position in the chain — without changing any other handler at all.
What Is Chain of Responsibility? #
Chain of Responsibility (CoR) is a behavioral design pattern that flows a request through a sequence of handlers, where each handler decides: handle this request, pass it to the next handler, or stop the chain with an error.
There are two different operation modes in Chain of Responsibility:
- Stop-first — a handler stops the chain as soon as one handles or fails; suited to validation and authentication
- Pipeline — every handler processes the request and passes it on; all handlers always execute; suited to data transformation and enrichment
This difference matters because it determines how the chain behaves when a handler “handles” a request.
flowchart LR
subgraph "Stop-first (Validation)"
R1[Request] --> H1A[Auth\\nHandler]
H1A -->|"passes"| H2A[Rate\\nLimit]
H2A -->|"passes"| H3A[Business\\nHandler]
H1A -->|"fails → stop"| E1[Error 401]
H2A -->|"fails → stop"| E2[Error 429]
end
subgraph "Pipeline (Transformation)"
R2[Request] --> H1B[Log\\nHandler]
H1B -->|"log + pass on"| H2B[Enrich\\nHandler]
H2B -->|"enrich + pass on"| H3B[Business\\nHandler]
endThe Problem It Solves #
Chain of Responsibility solves a very specific problem: how to organize a series of sequential handling steps without hardcoding the order and logic in one place.
The Problem: All Validation Piled in One Place #
// ANTI-PATTERN: all validation in one function — hard to test and hard to reorder
func HandleRequest(w http.ResponseWriter, r *http.Request) {
// Validation 1: authentication
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "unauthorized", 401)
return
}
claims, err := validateJWT(token)
if err != nil {
http.Error(w, "invalid token", 401)
return
}
// Validation 2: verified email
if !claims.EmailVerified {
http.Error(w, "email not verified", 403)
return
}
// Validation 3: rate limit
if exceeded := rateLimiter.Check(claims.UserID); exceeded {
http.Error(w, "rate limit exceeded", 429)
return
}
// Validation 4: request body size
if r.ContentLength > 1<<20 { // 1MB
http.Error(w, "request too large", 413)
return
}
// Only now the business logic
actualHandler(w, r)
// Adding new validation means coming here and typing new code
}
// CORRECT: every validation is a self-contained handler that can be chained
chain := BuildChain(
NewAuthHandler(jwtSvc),
NewEmailVerifiedHandler(),
NewRateLimitHandler(rateLimiter),
NewBodySizeHandler(1<<20),
)
chain.Handle(w, r)
// Adding a new validation = create a new handler, insert it into the chain
Two Handler Structures in Go #
Go has two idiomatic ways to implement Chain of Responsibility, each suited to a different context.
flowchart TD
subgraph "Struct-based (Linked List)"
direction LR
H1[Handler A\\nnext → ] --> H2[Handler B\\nnext → ] --> H3[Handler C\\nnext: nil]
end
subgraph "Functional (Middleware Stack)"
direction LR
F1["func(next Handler) Handler\\ncalls next inside"]
F2["Chained with wrap:\\nC(B(A(base)))"]
end| Approach | How It Works | Best For |
|---|---|---|
| Struct-based | Handler stores a reference to next; calls next.Handle() explicitly | Requests that can stop mid-chain (validation) |
| Functional middleware | A function receives next and returns a new handler; always calls next unless there is an error | HTTP middleware, gRPC interceptors |
Full Implementation: HTTP Middleware Chain #
Struct-based Handler Interface #
package middleware
import (
"context"
"fmt"
"net/http"
"time"
)
// Request carries all the data flowing through the handler chain.
// The Context field lets handlers write data for the next handler.
type Request struct {
HTTPRequest *http.Request
Context context.Context
UserID string
Claims map[string]interface{}
StartTime time.Time
}
// Response carries the result built by the handlers.
type Response struct {
StatusCode int
Body interface{}
Headers map[string]string
Error error
}
// Handler is the interface for every handler in the chain.
type Handler interface {
Handle(req *Request, res *Response)
SetNext(handler Handler)
Name() string
}
// BaseHandler provides the default implementation for SetNext and Handle.
// Embedded by all concrete handlers to avoid duplication.
type BaseHandler struct {
next Handler
}
func (b *BaseHandler) SetNext(handler Handler) {
b.next = handler
}
// PassToNext forwards the request to the next handler in the chain.
// Called by a concrete handler after it successfully processes the request.
func (b *BaseHandler) PassToNext(req *Request, res *Response) {
if b.next != nil {
b.next.Handle(req, res)
}
}
Concrete Handlers #
package middleware
import (
"fmt"
"net/http"
"strings"
"sync"
"time"
)
// AuthHandler validates the JWT token in the request.
type AuthHandler struct {
BaseHandler
jwtSecret string
}
func NewAuthHandler(secret string) Handler {
return &AuthHandler{jwtSecret: secret}
}
func (h *AuthHandler) Handle(req *Request, res *Response) {
authHeader := req.HTTPRequest.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
res.StatusCode = http.StatusUnauthorized
res.Error = fmt.Errorf("missing or malformed Authorization header")
return // stop the chain — do not call PassToNext
}
token := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := validateJWT(token, h.jwtSecret)
if err != nil {
res.StatusCode = http.StatusUnauthorized
res.Error = fmt.Errorf("invalid token: %w", err)
return
}
// Write the result to the request context for the next handler
req.UserID = claims["sub"].(string)
req.Claims = claims
h.PassToNext(req, res) // passes — forward to the next handler
}
func (h *AuthHandler) Name() string { return "AuthHandler" }
// EmailVerifiedHandler ensures the user has verified their email.
type EmailVerifiedHandler struct {
BaseHandler
}
func NewEmailVerifiedHandler() Handler {
return &EmailVerifiedHandler{}
}
func (h *EmailVerifiedHandler) Handle(req *Request, res *Response) {
emailVerified, ok := req.Claims["email_verified"].(bool)
if !ok || !emailVerified {
res.StatusCode = http.StatusForbidden
res.Error = fmt.Errorf("email not verified: please verify your email before continuing")
return
}
h.PassToNext(req, res)
}
func (h *EmailVerifiedHandler) Name() string { return "EmailVerifiedHandler" }
// RateLimitHandler limits the number of requests per user per minute.
type RateLimitHandler struct {
BaseHandler
mu sync.Mutex
counters map[string]*rateLimitEntry
maxPerMin int
}
type rateLimitEntry struct {
count int
resetAt time.Time
}
func NewRateLimitHandler(maxPerMinute int) Handler {
return &RateLimitHandler{
counters: make(map[string]*rateLimitEntry),
maxPerMin: maxPerMinute,
}
}
func (h *RateLimitHandler) Handle(req *Request, res *Response) {
h.mu.Lock()
entry, ok := h.counters[req.UserID]
if !ok || time.Now().After(entry.resetAt) {
entry = &rateLimitEntry{count: 0, resetAt: time.Now().Add(time.Minute)}
h.counters[req.UserID] = entry
}
entry.count++
count := entry.count
h.mu.Unlock()
if count > h.maxPerMin {
res.StatusCode = http.StatusTooManyRequests
res.Error = fmt.Errorf("rate limit exceeded: max %d requests per minute", h.maxPerMin)
res.Headers = map[string]string{
"Retry-After": "60",
"X-RateLimit-Limit": fmt.Sprintf("%d", h.maxPerMin),
}
return
}
h.PassToNext(req, res)
}
func (h *RateLimitHandler) Name() string { return "RateLimitHandler" }
// BodySizeHandler rejects requests with oversized bodies.
type BodySizeHandler struct {
BaseHandler
maxBytes int64
}
func NewBodySizeHandler(maxBytes int64) Handler {
return &BodySizeHandler{maxBytes: maxBytes}
}
func (h *BodySizeHandler) Handle(req *Request, res *Response) {
if req.HTTPRequest.ContentLength > h.maxBytes {
res.StatusCode = http.StatusRequestEntityTooLarge
res.Error = fmt.Errorf("request body too large: max %d bytes", h.maxBytes)
return
}
h.PassToNext(req, res)
}
func (h *BodySizeHandler) Name() string { return "BodySizeHandler" }
// LoggingHandler records every request — it always passes to the next handler.
type LoggingHandler struct {
BaseHandler
logger Logger
}
func NewLoggingHandler(logger Logger) Handler {
return &LoggingHandler{logger: logger}
}
func (h *LoggingHandler) Handle(req *Request, res *Response) {
start := time.Now()
h.PassToNext(req, res) // pass first, log after finishing
duration := time.Since(start)
h.logger.Info("request processed",
"user_id", req.UserID,
"method", req.HTTPRequest.Method,
"path", req.HTTPRequest.URL.Path,
"status", res.StatusCode,
"duration_ms", duration.Milliseconds(),
"error", res.Error,
)
}
func (h *LoggingHandler) Name() string { return "LoggingHandler" }
Chain Builder #
package middleware
// ChainBuilder makes assembling a handler chain easy.
type ChainBuilder struct {
handlers []Handler
}
func NewChainBuilder() *ChainBuilder {
return &ChainBuilder{}
}
func (b *ChainBuilder) Add(handlers ...Handler) *ChainBuilder {
b.handlers = append(b.handlers, handlers...)
return b
}
// Build chains all handlers into a linked list and returns the first handler.
func (b *ChainBuilder) Build() Handler {
if len(b.handlers) == 0 {
return nil
}
// Chain from back to front
for i := len(b.handlers) - 2; i >= 0; i-- {
b.handlers[i].SetNext(b.handlers[i+1])
}
return b.handlers[0]
}
// Usage
func buildAPIChain(jwtSecret string, rateLimiter *RateLimitHandler) Handler {
return NewChainBuilder().
Add(NewLoggingHandler(logger)). // 1. log all requests
Add(NewAuthHandler(jwtSecret)). // 2. validate the token
Add(NewEmailVerifiedHandler()). // 3. check verified email
Add(NewRateLimitHandler(100)). // 4. check the rate limit
Add(NewBodySizeHandler(1 << 20)). // 5. check the body size
Build()
}
Functional Middleware: The Idiomatic HTTP Style #
In Go, HTTP middleware is the most natural CoR implementation — every middleware is a function that takes an http.Handler and returns an http.Handler.
// Middleware is the function type for HTTP middleware
type Middleware func(http.Handler) http.Handler
// Chain composes several middleware into one handler.
func Chain(handler http.Handler, middlewares ...Middleware) http.Handler {
// Apply in reverse order so execution runs left to right
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}
// LoggingMiddleware records every request and its duration.
func LoggingMiddleware(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap the ResponseWriter to capture the status code
wrapped := &statusRecorder{ResponseWriter: w, statusCode: 200}
next.ServeHTTP(wrapped, r) // pass to the next handler
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.statusCode,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
}
// statusRecorder captures the status code written to the ResponseWriter
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
func (r *statusRecorder) WriteHeader(code int) {
r.statusCode = code
r.ResponseWriter.WriteHeader(code)
}
// AuthMiddleware validates a JWT and injects user info into the context.
func AuthMiddleware(secret string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return // stop the chain — do not call next
}
claims, err := validateJWT(token, secret)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
// Inject the claims into the context for the next handler
ctx := context.WithValue(r.Context(), contextKeyUserID, claims["sub"])
next.ServeHTTP(w, r.WithContext(ctx)) // pass on with the new context
})
}
}
// RateLimitMiddleware limits requests per IP.
func RateLimitMiddleware(maxPerMin int) Middleware {
limiter := newIPRateLimiter(maxPerMin)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if !limiter.Allow(ip) {
w.Header().Set("Retry-After", "60")
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
// RecoveryMiddleware catches panics and turns them into 500 errors.
func RecoveryMiddleware(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Error("panic recovered",
"panic", fmt.Sprintf("%v", rec),
"path", r.URL.Path,
)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
// CORSMiddleware adds CORS headers.
func CORSMiddleware(allowedOrigins []string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
for _, allowed := range allowedOrigins {
if allowed == "*" || allowed == origin {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
break
}
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// Assembly — order from outside to inside
func setupRouter() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/api/orders", handleOrders)
mux.HandleFunc("/api/users", handleUsers)
return Chain(mux,
RecoveryMiddleware(logger), // outermost: recovery first
LoggingMiddleware(logger), // then log
CORSMiddleware([]string{"*"}), // then CORS
RateLimitMiddleware(100), // then rate limit
AuthMiddleware(cfg.JWTSecret), // then auth
)
}
Pipeline: All Handlers Always Execute #
The pipeline mode differs from stop-first — every handler processes and passes on the request, building the result incrementally. Useful for data transformation.
// DataPipeline processes data through a series of sequential transformers.
type DataTransformer interface {
Transform(data map[string]interface{}) (map[string]interface{}, error)
Name() string
}
// Pipeline runs all transformers in sequence.
// Unlike stop-first CoR — all transformers always execute.
type Pipeline struct {
transformers []DataTransformer
}
func NewPipeline(transformers ...DataTransformer) *Pipeline {
return &Pipeline{transformers: transformers}
}
func (p *Pipeline) Process(data map[string]interface{}) (map[string]interface{}, error) {
current := data
for _, t := range p.transformers {
result, err := t.Transform(current)
if err != nil {
return nil, fmt.Errorf("transformer %s failed: %w", t.Name(), err)
}
current = result
}
return current, nil
}
// Example concrete transformers
type SanitizeTransformer struct{}
func (t *SanitizeTransformer) Transform(data map[string]interface{}) (map[string]interface{}, error) {
result := make(map[string]interface{})
for k, v := range data {
if str, ok := v.(string); ok {
result[k] = strings.TrimSpace(str)
} else {
result[k] = v
}
}
return result, nil
}
func (t *SanitizeTransformer) Name() string { return "Sanitize" }
type NormalizeEmailTransformer struct{}
func (t *NormalizeEmailTransformer) Transform(data map[string]interface{}) (map[string]interface{}, error) {
result := make(map[string]interface{})
for k, v := range data {
result[k] = v
}
if email, ok := result["email"].(string); ok {
result["email"] = strings.ToLower(email)
}
return result, nil
}
func (t *NormalizeEmailTransformer) Name() string { return "NormalizeEmail" }
type EnrichWithTimestampTransformer struct{}
func (t *EnrichWithTimestampTransformer) Transform(data map[string]interface{}) (map[string]interface{}, error) {
result := make(map[string]interface{})
for k, v := range data {
result[k] = v
}
result["processed_at"] = time.Now().UTC().Format(time.RFC3339)
return result, nil
}
func (t *EnrichWithTimestampTransformer) Name() string { return "EnrichTimestamp" }
// Pipeline usage
func processUserRegistration(input map[string]interface{}) (map[string]interface{}, error) {
pipeline := NewPipeline(
&SanitizeTransformer{},
&NormalizeEmailTransformer{},
&EnrichWithTimestampTransformer{},
)
return pipeline.Process(input)
}
Case Study: Approval Workflow #
Chain of Responsibility is also very well suited to tiered approval systems — requests are processed by different approvers based on value or request type.
// ApprovalRequest represents a request that needs approval.
type ApprovalRequest struct {
ID string
Type string
Amount float64
RequestedBy string
Description string
}
// ApprovalResult stores the outcome of the approval process.
type ApprovalResult struct {
Approved bool
ApprovedBy string
Reason string
}
// Approver is a handler for the approval workflow.
type Approver interface {
Approve(req ApprovalRequest) (*ApprovalResult, error)
SetNext(approver Approver)
Title() string
}
// BaseApprover provides the SetNext implementation.
type BaseApprover struct {
next Approver
}
func (a *BaseApprover) SetNext(approver Approver) { a.next = approver }
func (a *BaseApprover) passToNext(req ApprovalRequest) (*ApprovalResult, error) {
if a.next != nil {
return a.next.Approve(req)
}
return &ApprovalResult{
Approved: false,
Reason: "no approver available for this request",
}, nil
}
// TeamLeadApprover approves requests up to 10 million.
type TeamLeadApprover struct {
BaseApprover
name string
threshold float64
}
func NewTeamLeadApprover(name string) *TeamLeadApprover {
return &TeamLeadApprover{name: name, threshold: 10_000_000}
}
func (a *TeamLeadApprover) Approve(req ApprovalRequest) (*ApprovalResult, error) {
if req.Amount <= a.threshold {
fmt.Printf("[%s] Approving request %s (Rp %.0f)\n", a.Title(), req.ID, req.Amount)
return &ApprovalResult{
Approved: true,
ApprovedBy: a.name,
Reason: fmt.Sprintf("amount within team lead threshold (Rp %.0f)", a.threshold),
}, nil
}
fmt.Printf("[%s] Forwarding to the next level (Rp %.0f exceeds threshold)\n",
a.Title(), req.Amount)
return a.passToNext(req)
}
func (a *TeamLeadApprover) Title() string { return "Team Lead" }
// ManagerApprover approves requests up to 100 million.
type ManagerApprover struct {
BaseApprover
name string
threshold float64
}
func NewManagerApprover(name string) *ManagerApprover {
return &ManagerApprover{name: name, threshold: 100_000_000}
}
func (a *ManagerApprover) Approve(req ApprovalRequest) (*ApprovalResult, error) {
if req.Amount <= a.threshold {
fmt.Printf("[%s] Approving request %s (Rp %.0f)\n", a.Title(), req.ID, req.Amount)
return &ApprovalResult{
Approved: true,
ApprovedBy: a.name,
}, nil
}
return a.passToNext(req)
}
func (a *ManagerApprover) Title() string { return "Manager" }
// DirectorApprover approves every request — the highest level.
type DirectorApprover struct {
BaseApprover
name string
}
func NewDirectorApprover(name string) *DirectorApprover {
return &DirectorApprover{name: name}
}
func (a *DirectorApprover) Approve(req ApprovalRequest) (*ApprovalResult, error) {
fmt.Printf("[%s] Approving request %s (Rp %.0f)\n", a.Title(), req.ID, req.Amount)
return &ApprovalResult{
Approved: true,
ApprovedBy: a.name,
Reason: "approved by director",
}, nil
}
func (a *DirectorApprover) Title() string { return "Director" }
// Assembling the approval chain
func buildApprovalChain() Approver {
teamLead := NewTeamLeadApprover("Budi")
manager := NewManagerApprover("Citra")
director := NewDirectorApprover("Dani")
teamLead.SetNext(manager)
manager.SetNext(director)
return teamLead // the entry point is always the first handler
}
// Usage
func main() {
chain := buildApprovalChain()
requests := []ApprovalRequest{
{ID: "REQ-001", Amount: 5_000_000, RequestedBy: "Eka"}, // Team Lead approves
{ID: "REQ-002", Amount: 50_000_000, RequestedBy: "Fajar"}, // Manager approves
{ID: "REQ-003", Amount: 500_000_000, RequestedBy: "Gita"}, // Director approves
}
for _, req := range requests {
result, err := chain.Approve(req)
if err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
fmt.Printf("Result: approved=%v by=%s\n\n", result.Approved, result.ApprovedBy)
}
}
Output:
[Team Lead] Approving request REQ-001 (Rp 5000000)
Result: approved=true by=Budi
[Team Lead] Forwarding to the next level (Rp 50000000 exceeds threshold)
[Manager] Approving request REQ-002 (Rp 50000000)
Result: approved=true by=Citra
[Team Lead] Forwarding to the next level (Rp 500000000 exceeds threshold)
[Manager] Forwarding to the next level
[Director] Approving request REQ-003 (Rp 500000000)
Result: approved=true by=Dani
Testing Chain of Responsibility #
// MockHandler for testing — records whether it was called and whether it passed on
type MockHandler struct {
BaseHandler
name string
shouldStop bool // true = stop the chain (simulating a failure)
called bool
}
func (h *MockHandler) Handle(req *Request, res *Response) {
h.called = true
if h.shouldStop {
res.StatusCode = http.StatusUnauthorized
res.Error = fmt.Errorf("stopped by %s", h.name)
return
}
h.PassToNext(req, res)
}
func (h *MockHandler) Name() string { return h.name }
func TestChain_StopsAtFailingHandler(t *testing.T) {
h1 := &MockHandler{name: "H1", shouldStop: false}
h2 := &MockHandler{name: "H2", shouldStop: true} // this one fails
h3 := &MockHandler{name: "H3", shouldStop: false}
chain := NewChainBuilder().Add(h1, h2, h3).Build()
req := &Request{HTTPRequest: &http.Request{Header: http.Header{}}}
res := &Response{StatusCode: 200}
chain.Handle(req, res)
if !h1.called {
t.Error("H1 should have been called")
}
if !h2.called {
t.Error("H2 should have been called")
}
if h3.called {
t.Error("H3 should NOT have been called after H2 stopped the chain")
}
if res.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", res.StatusCode)
}
}
func TestChain_AllHandlersCalled_WhenAllPass(t *testing.T) {
h1 := &MockHandler{name: "H1"}
h2 := &MockHandler{name: "H2"}
h3 := &MockHandler{name: "H3"}
chain := NewChainBuilder().Add(h1, h2, h3).Build()
req := &Request{HTTPRequest: &http.Request{Header: http.Header{}}}
res := &Response{StatusCode: 200}
chain.Handle(req, res)
if !h1.called || !h2.called || !h3.called {
t.Error("all handlers should be called when all pass")
}
}
func TestApprovalChain_RoutesToCorrectLevel(t *testing.T) {
chain := buildApprovalChain()
tests := []struct {
amount float64
expectedBy string
}{
{5_000_000, "Budi"}, // Team Lead
{50_000_000, "Citra"}, // Manager
{500_000_000, "Dani"}, // Director
}
for _, tt := range tests {
result, err := chain.Approve(ApprovalRequest{
ID: "test", Amount: tt.amount, RequestedBy: "requester",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Approved {
t.Errorf("expected approval for amount %.0f", tt.amount)
}
if result.ApprovedBy != tt.expectedBy {
t.Errorf("expected approved by %s, got %s", tt.expectedBy, result.ApprovedBy)
}
}
}
When to Use and When Not to #
USE Chain of Responsibility if:
✓ There are several handlers that can process a request in sequence
✓ Which handler processes is determined at runtime based on request conditions
✓ You want to add, remove, or reorder handlers without modifying the client
✓ You are building a validation, transformation, or tiered approval pipeline
✓ HTTP middleware — this is the most common CoR implementation
AVOID Chain of Responsibility if:
✗ A single handler always processes — use Strategy
✗ The chain is very long (>10 handlers) — debugging becomes very hard
✗ Handlers need to know about other handlers — a sign of a design problem
✗ All handlers must be called unconditionally — use the Observer Pattern
Overly Long Chains Are Hard to Debug
If a request fails somewhere in a long chain, tracing which handler failed can take time. Every handler should log itself well (the
Name()method), and there should ideally be chain-level logging that records every handler-to-handler transition. Limit a chain to around 5-7 handlers maximum for one context; if it exceeds that, consider splitting it into several sub-chains.
Chain of Responsibility Review Checklist #
DESIGN:
□ Each handler has one clear responsibility
□ Handlers do not depend on other handlers directly
□ The handler order in the chain is documented and changeable without modifying handlers
□ There is handling for the case where no handler processes the request
IMPLEMENTATION:
□ BaseHandler avoids duplicating SetNext and PassToNext code
□ Handlers that stop the chain do NOT call PassToNext/next
□ Handlers that pass on ALWAYS call PassToNext/next
□ Every handler has a Name() for logging and debugging
LOGGING:
□ Every processing handler logs its activity
□ Handlers that stop the chain log their reason
□ There is chain-level logging that shows the request flow
TESTING:
□ Test that a failing handler stops the chain
□ Test that all handlers are called when all pass
□ Test each concrete handler in isolation (without the chain)
□ Test that adding a new handler does not break existing ones
Summary #
- Chain of Responsibility flows a request through a sequence of handlers — each handler decides whether to handle, pass on, or stop the chain.
- Two operation modes: stop-first for validation (stops at the first failing handler) and pipeline for transformation (all handlers always execute).
- HTTP middleware is the most common CoR implementation in Go —
func(http.Handler) http.Handleris a functional handler chained withChain().- BaseHandler eliminates duplication —
SetNextandPassToNextare implemented once; concrete handlers only focus on their own logic.- Handler order is very important: Recovery → Logging → CORS → RateLimit → Auth is a different order from Auth → RateLimit → Recovery — understand the consequences of each order.
- Approval workflows are another natural use case — requests are processed by approvers from the lowest to the highest level, stopping when someone authorized handles it.
- Don’t make chains too long — more than 5-7 handlers in one context is hard to debug; split into sub-chains if needed.
- Every handler must be self-contained and testable — not dependent on other handlers; it can be tested in isolation with mocks for the handlers before and after it.