Skip to main content
spring internals

Scoped Proxies and Injecting Narrow-Scoped Beans into Singletons

9 min read Chapter 72 of 78

Scoped Proxies and Injecting Narrow-Scoped Beans into Singletons

The SaaS backend has a TenantContext bean that holds the current tenant’s ID, plan tier, and feature flags. It is request-scoped: each HTTP request resolves the tenant from a header and populates a fresh TenantContext. The OrderService is a singleton. It needs the current tenant on every request. These two scopes are incompatible without a proxy.

This section explains exactly how scoped proxies bridge the gap, what happens at the bytecode level, and when ObjectProvider is a better choice.

The Problem, Precisely

A singleton is created once during ApplicationContext.refresh(). At that moment, there is no HTTP request. There is no RequestAttributes in the ThreadLocal. If Spring tries to resolve a request-scoped dependency during singleton creation, it fails:

// BROKEN: Request-scoped bean injected into singleton without proxy
@Component
@Scope("request")
public class TenantContext {
    private String tenantId;
    private TenantPlan plan;

    public void setTenantId(String tenantId) { this.tenantId = tenantId; }
    public String getTenantId() { return tenantId; }
    public TenantPlan getPlan() { return plan; }
    public void setPlan(TenantPlan plan) { this.plan = plan; }
}

@Service
public class OrderService {
    private final TenantContext tenantContext;

    public OrderService(TenantContext tenantContext) {
        // Fails at startup:
        // IllegalStateException: No thread-bound request found
        this.tenantContext = tenantContext;
    }
}

Spring cannot create TenantContext because RequestScope.get() calls RequestContextHolder.currentRequestAttributes(), which throws when no request is active. The application does not start.

If you work around this with @Lazy, the proxy defers creation to first use. But then TenantContext is created on the first request and that same instance is used for all subsequent requests. Tenant A’s context is served to Tenant B. This is worse than a startup failure because it is a silent correctness bug.

How the Scoped Proxy Works

Adding proxyMode = ScopedProxyMode.TARGET_CLASS changes what Spring injects:

// CORRECT: Scoped proxy delegates per invocation
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class TenantContext {
    private String tenantId;
    private TenantPlan plan;

    public void setTenantId(String tenantId) { this.tenantId = tenantId; }
    public String getTenantId() { return tenantId; }
    public TenantPlan getPlan() { return plan; }
    public void setPlan(TenantPlan plan) { this.plan = plan; }
}

During component scanning, ScopedProxyUtils.createScopedProxy() modifies the bean definition. It creates two definitions:

  1. Target bean definition: The original TenantContext, renamed to scopedTarget.tenantContext. This is the actual request-scoped bean.
  2. Proxy bean definition: A singleton-scoped bean named tenantContext that creates a CGLIB proxy.

The proxy class is generated at context startup using the same CGLIB infrastructure described in CH8. The proxy extends TenantContext (subclass-based proxy). It is a singleton, so it can be safely injected into OrderService.

When a method is called on the proxy, the CGLIB MethodInterceptor does not call the method on the proxy object. It does this:

// Simplified scoped proxy interceptor logic
public Object intercept(Object obj, Method method, Object[] args,
                        MethodProxy proxy) throws Throwable {
    // 1. Get the current scope
    Scope scope = beanFactory.getRegisteredScope("request");

    // 2. Look up the real bean in the current request's scope
    Object target = scope.get("scopedTarget.tenantContext", () -> {
        return beanFactory.createBean(TenantContext.class);
    });

    // 3. Invoke the method on the real bean
    return method.invoke(target, args);
}

Every method call triggers a scope lookup. On the first call within a request, the real TenantContext is created and stored in request attributes. Subsequent calls within the same request return the same instance. Different requests get different instances.

Verifying the Proxy at Runtime

@Service
public class OrderService {
    private final TenantContext tenantContext;

    public OrderService(TenantContext tenantContext) {
        this.tenantContext = tenantContext;
    }

    @PostConstruct
    void inspectInjection() {
        Class<?> clazz = tenantContext.getClass();
        System.out.println("Injected type: " + clazz.getName());
        // com.saas.context.TenantContext$$SpringCGLIB$$0

        System.out.println("Is proxy: " + clazz.getName().contains("$$"));
        // true

        System.out.println("Superclass: " + clazz.getSuperclass().getName());
        // com.saas.context.TenantContext

        // The proxy is a singleton. Same reference every time.
        // But method calls resolve to the current request's instance.
    }

    public Order createOrder(OrderRequest request) {
        // Each call resolves TenantContext from the current request
        String tenantId = tenantContext.getTenantId();
        TenantPlan plan = tenantContext.getPlan();

        if (plan == TenantPlan.FREE && request.getItems().size() > 10) {
            throw new PlanLimitExceededException(tenantId, "FREE", 10);
        }

        return orderRepository.save(new Order(tenantId, request));
    }
}

TARGET_CLASS vs INTERFACES

ScopedProxyMode has two proxy options:

TARGET_CLASS: Creates a CGLIB subclass proxy. Works with concrete classes. The proxy extends the target class. This is the default choice and works in nearly all cases.

@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class TenantContext { ... }
// Proxy: TenantContext$$SpringCGLIB$$0 extends TenantContext

Constraints (from CH8):

  • Target class must not be final.
  • final methods on the target are not intercepted. The proxy calls the method on itself, not on the scoped target. This silently bypasses scope resolution.
  • The proxy’s no-arg constructor is called during proxy creation (CGLIB uses Objenesis by default to avoid this, but edge cases exist).

INTERFACES: Creates a JDK dynamic proxy. The proxy implements the same interfaces as the target. The proxy is not a subclass.

public interface TenantContextProvider {
    String getTenantId();
    TenantPlan getPlan();
}

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.INTERFACES)
public class TenantContext implements TenantContextProvider {
    private String tenantId;
    private TenantPlan plan;

    @Override
    public String getTenantId() { return tenantId; }
    @Override
    public TenantPlan getPlan() { return plan; }
}

With INTERFACES, the injection point must use the interface type:

@Service
public class OrderService {
    private final TenantContextProvider tenantContext; // Interface type
    // JDK proxy implements TenantContextProvider
}

Use INTERFACES when:

  • The target class is final and cannot be subclassed.
  • You want to enforce interface-based design.
  • You are working with multiple interfaces and want the proxy to implement only specific ones.

In the SaaS backend, TARGET_CLASS is the pragmatic default.

ObjectProvider as an Alternative

ObjectProvider<T> avoids scoped proxies entirely. Instead of injecting the bean, you inject a provider that lazily retrieves the bean from the scope on demand:

@Service
public class OrderService {
    private final ObjectProvider<TenantContext> tenantContextProvider;

    public Order createOrder(OrderRequest request) {
        TenantContext tenantContext = tenantContextProvider.getObject();
        // getObject() calls getBean() which calls RequestScope.get()
        // Returns the current request's TenantContext

        String tenantId = tenantContext.getTenantId();
        // ...
    }
}

ObjectProvider is a Spring 4.3+ interface that wraps BeanFactory.getBean(). For request-scoped beans, each getObject() call within the same request returns the same instance (because the request scope caches it). Different requests get different instances.

When to Use ObjectProvider Over Scoped Proxy

AspectScoped ProxyObjectProvider
Injection transparencyLooks like a regular beanExplicit provider pattern
Method call overheadScope lookup per method callScope lookup per getObject()
Final class supportFails with TARGET_CLASSWorks with any class
Null handlingThrows if no instancegetIfAvailable() returns null
Multiple calls per requestEach method call is proxiedCache the result in a local variable

If you call many methods on the scoped bean within a single method body, ObjectProvider is more efficient. You call getObject() once, store the result, and call methods directly without proxy overhead:

public Order createOrder(OrderRequest request) {
    TenantContext ctx = tenantContextProvider.getObject(); // One scope lookup
    String tenantId = ctx.getTenantId();    // Direct call, no proxy
    TenantPlan plan = ctx.getPlan();         // Direct call, no proxy
    // ...
}

With a scoped proxy, each of getTenantId() and getPlan() triggers a separate scope lookup. In practice, the overhead is negligible because RequestScope.get() is a HashMap.get() on request attributes. But for hot paths, the difference is measurable.

The Final Method Trap

This is the subtlest failure mode. If TenantContext has a final method and uses TARGET_CLASS:

// BROKEN: Final method on scoped proxy target
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class TenantContext {
    private String tenantId;

    public final String getTenantId() { return tenantId; }
    // CGLIB cannot override final methods.
    // Proxy calls getTenantId() on the PROXY object, not the scoped target.
    // Returns null (proxy's field is never set).
}

CGLIB generates a subclass. final methods cannot be overridden in a subclass. The proxy’s getTenantId() executes on the proxy instance, which has tenantId = null. The real request-scoped instance, which has the correct tenantId, is never consulted.

This produces a NullPointerException or, worse, silently returns null and the application processes the order with no tenant ID.

The fix:

// CORRECT: Remove final from methods that need proxying
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class TenantContext {
    private String tenantId;

    public String getTenantId() { return tenantId; } // Not final
}

Or switch to INTERFACES mode with a non-final interface contract.

Testing Scoped Beans

In integration tests, @WebMvcTest and @SpringBootTest with MockMvc provide a request scope automatically. In unit tests or non-web test slices, there is no request scope. Accessing a request-scoped bean throws:

java.lang.IllegalStateException:
  No thread-bound request found: Are you referring to request attributes
  outside of an actual web request?

Use @RequestScope test support:

@SpringBootTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Test
    void createOrderUsesCorrectTenant() {
        // SimpleRequestAttributes provides a mock request scope
        RequestAttributes attrs = new SimpleRequestAttributes(
            new MockHttpServletRequest());
        RequestContextHolder.setRequestAttributes(attrs);

        try {
            // TenantContext scoped proxy now resolves correctly
            Order order = orderService.createOrder(new OrderRequest(...));
            assertThat(order.getTenantId()).isEqualTo("tenant-123");
        } finally {
            RequestContextHolder.resetRequestAttributes();
        }
    }
}

Or use the @MockBean approach to replace the scoped bean entirely in tests where the scope mechanism is not under test.

Summary

The scoped proxy is a CGLIB (or JDK) proxy that acts as a scope-aware indirection layer. It is a singleton that delegates every method call to the real bean resolved from the current scope. Without it, injecting a narrow-scoped bean into a wider scope results in stale data, null references, or startup failures. The proxy bridges scope lifetimes transparently, with one constraint: the target class must be proxyable (non-final class, non-final methods for TARGET_CLASS mode). When that constraint is unacceptable, ObjectProvider offers the same scope resolution with explicit control.