StackPractices
intermediate By Mathias Paulenko

Context Object Pattern: Examples

Reduce parameter bloat by passing a single context object through the call chain. Examples in Python, Java, and JavaScript for request contexts.

Overview

Every layer of a web app wants the same five things: the request id, the authenticated user, the logger, the config, maybe a correlation id for tracing. The naive version is a method signature like process(userId, requestId, logger, config, db, cache, data) — and it gets worse every time a new cross-cutting concern appears, because every intermediate signature has to change too.

The Context Object Pattern packs that shared state into a single carrier object created once at the system boundary — the middleware, the request handler — and passed down unchanged. Signatures become process(ctx, data): business parameters stay visible, infrastructure concerns travel along for free.

flowchart diagram: REQ[HTTP request

When to Use

  • Several layers need the same cross-cutting data — tracing ids, user, locale, feature flags
  • Method signatures keep accumulating the same repeated parameters
  • You want request-scoped state without global variables or thread-local magic
  • Components should stay decoupled from the runtime environment they execute in

For a coarser facade over remote services, the Business Delegate Pattern is the better fit — it hides a whole subsystem rather than carrying request state.

When to Avoid

  • Methods that need one or two parameters — a context object there is ceremony
  • When the context starts collecting unrelated concerns (a God object in disguise)
  • When explicit parameters would make a dependency clearer — don’t hide real inputs inside ctx
  • Hot paths where the extra allocation matters more than readability

Solution

Python

from dataclasses import dataclass, field
from typing import Any, ClassVar
from datetime import datetime
import logging
import uuid

@dataclass(frozen=True)
class RequestContext:
    """Request-scoped state, immutable after construction."""
    request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
    timestamp: datetime = field(default_factory=datetime.now)
    user_id: str | None = None
    correlation_id: str | None = None
    metadata: dict[str, Any] = field(default_factory=dict)

    def with_user(self, user_id: str) -> "RequestContext":
        # frozen dataclass: build a copy instead of mutating
        from dataclasses import replace
        return replace(self, user_id=user_id, metadata=dict(self.metadata))


class OrderService:
    def process_order(self, ctx: RequestContext, order_data: dict) -> dict:
        logging.info("[%s] Processing order for %s", ctx.request_id, ctx.user_id)
        validated = self._validate(order_data)
        return self._persist(ctx, validated)

    def _validate(self, data: dict) -> dict:
        return {**data, "validated": True}

    def _persist(self, ctx: RequestContext, data: dict) -> dict:
        logging.info("[%s] Persisting order", ctx.request_id)
        return {**data, "order_id": "ORD-123"}


# The boundary layer builds the context once
class RequestHandler:
    def __init__(self, service: OrderService):
        self.service = service

    def handle_request(self, raw_request: dict) -> dict:
        ctx = RequestContext(
            user_id=raw_request.get("user_id"),
            correlation_id=raw_request.get("correlation_id"),
        )
        return self.service.process_order(ctx, raw_request.get("order_data", {}))


handler = RequestHandler(OrderService())
result = handler.handle_request({
    "user_id": "user-42",
    "correlation_id": "corr-abc",
    "order_data": {"items": ["book", "pen"]},
})
print(result["order_id"])  # ORD-123

Java

import java.time.Instant;
import java.util.*;

public class RequestContext {
    private final String requestId;
    private final Instant timestamp;
    private final String userId;
    private final String correlationId;
    private final Map<String, Object> metadata;

    private RequestContext(Builder b) {
        this.requestId = b.requestId;
        this.timestamp = b.timestamp;
        this.userId = b.userId;
        this.correlationId = b.correlationId;
        this.metadata = Collections.unmodifiableMap(new HashMap<>(b.metadata));
    }

    public String getRequestId() { return requestId; }
    public String getUserId() { return userId; }
    public String getCorrelationId() { return correlationId; }
    public Map<String, Object> getMetadata() { return metadata; }

    public static class Builder {
        private String requestId = UUID.randomUUID().toString();
        private Instant timestamp = Instant.now();
        private String userId;
        private String correlationId;
        private Map<String, Object> metadata = new HashMap<>();

        public Builder userId(String v) { this.userId = v; return this; }
        public Builder correlationId(String v) { this.correlationId = v; return this; }
        public Builder metadata(String k, Object v) { this.metadata.put(k, v); return this; }
        public RequestContext build() { return new RequestContext(this); }
    }
}

class OrderService {
    public Map<String, Object> processOrder(RequestContext ctx, Map<String, Object> orderData) {
        System.out.println("[" + ctx.getRequestId() + "] Order for " + ctx.getUserId());
        Map<String, Object> result = new HashMap<>(orderData);
        result.put("order_id", "ORD-123");
        return result;
    }
}

// Boundary: build the context once, pass it down
class RequestHandler {
    private final OrderService service;
    RequestHandler(OrderService service) { this.service = service; }

    public Map<String, Object> handleRequest(Map<String, Object> raw) {
        RequestContext ctx = new RequestContext.Builder()
            .userId((String) raw.get("user_id"))
            .correlationId((String) raw.get("correlation_id"))
            .build();
        return service.processOrder(ctx, (Map<String, Object>) raw.get("order_data"));
    }
}

RequestHandler handler = new RequestHandler(new OrderService());
Map<String, Object> request = Map.of(
    "user_id", "user-42",
    "order_data", Map.of("items", List.of("book", "pen"))
);
System.out.println(handler.handleRequest(request).get("order_id"));

JavaScript

class RequestContext {
  constructor({ userId = null, correlationId = null, metadata = new Map() } = {}) {
    this.requestId = crypto.randomUUID();
    this.timestamp = new Date();
    this.userId = userId;
    this.correlationId = correlationId;
    this.metadata = metadata;
    Object.freeze(this); // no mid-request mutations
  }

  withUser(userId) {
    return new RequestContext({ userId, correlationId: this.correlationId, metadata: new Map(this.metadata) });
  }
}

class OrderService {
  processOrder(ctx, orderData) {
    console.log(`[${ctx.requestId}] Order for ${ctx.userId}`);
    return { ...orderData, orderId: 'ORD-123' };
  }
}

// Boundary: build once in middleware, pass down
class RequestHandler {
  constructor(service) { this.service = service; }
  handleRequest(raw) {
    const ctx = new RequestContext({ userId: raw.user_id, correlationId: raw.correlation_id });
    return this.service.processOrder(ctx, raw.order_data || {});
  }
}

const handler = new RequestHandler(new OrderService());
const result = handler.handleRequest({
  user_id: 'user-42',
  correlation_id: 'corr-abc',
  order_data: { items: ['book', 'pen'] },
});
console.log(result.orderId); // ORD-123

Companion code: the context-object-pattern companion repo has all three implementations ready to run.

Explanation

The mechanics are simple — the discipline is in where the object lives:

  • Created once at the boundary. Middleware, a request handler, or a message consumer builds the context; deeper layers only read it. If components down the stack are constructing their own context, the pattern is already broken.
  • Immutable by default. Mutating shared context mid-request leaks side effects between callers. Prefer withUser()-style copies — in Python that’s a frozen dataclass with replace(), in JS Object.freeze, in Java an immutable class with a builder.
  • Reads like infrastructure, not business data. Tracing ids, locale, tenant, auth user — fine. The order being processed — that’s a real parameter and should stay one. The Dependency Injection Pattern handles the service-wiring half of this problem; the context object handles the per-request state half.

The async caveat

The pattern’s classic failure is losing the context across an await boundary. Node.js solves it with AsyncLocalStorage, Python asyncio with contextvars, Java historically with ThreadLocal (or Reactor’s context). Whatever mechanism you pick, the rule is the same: the context must survive async hops or your traces silently lose their request id.

Variants

VariantScopeTypical use
Request-scopedOne context per HTTP requestWeb frameworks, tracing
Thread-localStored in thread-local storageJava, C# async contexts
Async contextPropagated through async callsNode.js AsyncLocalStorage, Python contextvars
Global/singletonOne app-wide contextCLI tools, desktop apps

What Works

  • Keep contexts immutable. New instances instead of shared mutable state.
  • Scope them narrowly. Request-scoped, not global — singleton contexts are where this pattern becomes the anti-pattern people warn about.
  • No business logic in the context. It carries state and references, not rules.
  • Factory methods make immutability ergonomic. withUser(), withMetadata().
  • Type it. TypeScript generics or Java’s typed fields beat Map<String, Object> soup — a typed context is also self-documenting.

Common Mistakes

  • The context becomes a God object. Fifty fields means it needs splitting into focused contexts, not a bigger class.
  • Mutating it mid-request. Side effects leak between components and debugging becomes archaeology.
  • Hiding real inputs inside it. If a parameter is business data, it belongs in the signature — that’s how readers know the method needs it.
  • Dropping it at async boundaries. A context that doesn’t survive await/then breaks tracing exactly where it’s needed.
  • Storing heavy objects. A megabyte-sized blob in a request-scoped object is a GC tax on every call.

Real-World Examples

HTTP request contexts

Django’s request, Express’s req, and Go’s context.Context are all this pattern: request-scoped carriers that travel through middleware and handlers.

React Context API

createContext/useContext move data down the component tree without prop drilling — the same problem one level up, in the UI hierarchy.

Android Context

Android’s Context exposes resources, preferences, and system services across the app lifecycle — the global-scope variant, with the trade-offs that implies.

See Also

Further Reading

Frequently Asked Questions

What's the difference between Context Object and Dependency Injection?

DI wires services in at construction time; a context object carries runtime state through the call chain. Most real apps use both — DI for the service graph, a context for the request.

Is the Context Object an anti-pattern?

Only when it becomes a global variable with a nicer name. Kept request-scoped and immutable, it's the backbone of most web frameworks.

How do I propagate context through async code?

AsyncLocalStorage in Node.js, contextvars in Python asyncio, Reactor context or ThreadLocal in Java. Or just pass it as an argument — explicit always works.

When is a plain parameter better than a context object?

When the value is business input, not infrastructure. process(order) says more than process(ctx) if the method actually needs the order. Context is for the stuff every layer needs, not for hiding what a function consumes.

How do I test code that takes a context object?

Build a minimal test context in the test (RequestContext(userId="t") or a test factory) — that's the payoff: one object to stub instead of seven constructor arguments.