Scoped Proxies and Injecting Narrow-Scoped Beans into Singletons
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:
- Target bean definition: The original
TenantContext, renamed toscopedTarget.tenantContext. This is the actual request-scoped bean. - Proxy bean definition: A singleton-scoped bean named
tenantContextthat 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. finalmethods 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
Objenesisby 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
finaland 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
| Aspect | Scoped Proxy | ObjectProvider |
|---|---|---|
| Injection transparency | Looks like a regular bean | Explicit provider pattern |
| Method call overhead | Scope lookup per method call | Scope lookup per getObject() |
| Final class support | Fails with TARGET_CLASS | Works with any class |
| Null handling | Throws if no instance | getIfAvailable() returns null |
| Multiple calls per request | Each method call is proxied | Cache 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.