Scope Internals: Singleton, Prototype, Request, Session, and the Scoped Proxy
Scope Internals
Every bean in a Spring application has a scope. The scope determines how many instances exist, when they are created, and when they are destroyed. Most engineers never think about scope because most beans are singletons. That works until a request-scoped bean is injected into a singleton, and the SaaS backend starts serving tenant A’s data to tenant B.
This chapter explains how Spring implements each scope, what data structures back them, and why the scoped proxy mechanism exists.
Singleton Scope
Singleton is the default. One instance per ApplicationContext. Not one per JVM, not one per classloader. One per context.
The instance is stored in DefaultSingletonBeanRegistry.singletonObjects, a ConcurrentHashMap<String, Object>:
// DefaultSingletonBeanRegistry (simplified)
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
public Object getSingleton(String beanName) {
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null) {
// Check singletonFactories, earlySingletonObjects (circular ref support)
}
return singletonObject;
}
When getBean("orderService") is called, Spring checks singletonObjects first. If the bean exists, it returns the cached instance. If not, it creates the bean, runs BeanPostProcessor chains, and stores the result. Every subsequent call returns the same object reference.
Singletons are created eagerly at context startup (unless @Lazy is applied). The container initializes all singleton bean definitions during the refresh() phase. This means startup cost is paid once, and any wiring error surfaces immediately rather than at the first request.
For the SaaS backend, most services are singletons: OrderService, TenantRepository, NotificationGateway. They hold no per-request state. They receive request-specific data as method parameters.
@Service // Singleton by default
public class OrderService {
private final OrderRepository orderRepository;
private final TenantContext tenantContext;
// tenantContext is injected ONCE at startup
// If tenantContext is request-scoped, this is a problem
}
Prototype Scope
Prototype scope creates a new instance every time getBean() is called or every time the bean is injected into another bean. Spring does not cache prototype beans. It does not track them. It does not call @PreDestroy on them.
@Component
@Scope("prototype")
public class ReportGenerator {
private final List<ReportSection> sections = new ArrayList<>();
// Each injection gets a fresh ReportGenerator with an empty list
}
When Spring resolves a dependency on a prototype bean, it calls AbstractBeanFactory.doGetBean() which branches on scope:
if (mbd.isSingleton()) {
sharedInstance = getSingleton(beanName, () -> createBean(beanName, mbd, args));
beanInstance = getObjectForBeanInstance(sharedInstance, name, beanName, mbd);
} else if (mbd.isPrototype()) {
Object prototypeInstance = createBean(beanName, mbd, args);
beanInstance = getObjectForBeanInstance(prototypeInstance, name, beanName, mbd);
// No storage. No tracking. The instance is returned and forgotten.
}
There is no map for prototypes. No registry. Spring creates the object, runs post-processors, and hands it off. The caller owns the lifecycle from that point.
This has a critical consequence: if a prototype bean acquires resources (database connections, file handles, thread pools), Spring will not clean them up. @PreDestroy is never invoked because the container does not hold a reference to the instance. This is covered in detail in CH24-S1.
Request Scope
Request scope creates one instance per HTTP request. The instance is stored as a request attribute in the ServletRequest (or ServerWebExchange in reactive).
When getBean() is called for a request-scoped bean, Spring delegates to RequestScope, which extends AbstractRequestAttributesScope:
public class RequestScope extends AbstractRequestAttributesScope {
@Override
protected int getScope() {
return RequestAttributes.SCOPE_REQUEST;
}
}
// AbstractRequestAttributesScope
public Object get(String name, ObjectFactory<?> objectFactory) {
RequestAttributes attributes = RequestContextHolder.currentRequestAttributes();
Object scopedObject = attributes.getAttribute(name, getScope());
if (scopedObject == null) {
scopedObject = objectFactory.getObject();
attributes.setAttribute(name, scopedObject, getScope());
}
return scopedObject;
}
The bean is stored in RequestAttributes (backed by HttpServletRequest.setAttribute()). Each request thread gets its own RequestAttributes via RequestContextHolder, a ThreadLocal-based holder. When the request completes, RequestContextListener or DispatcherServlet destroys the scope, and @PreDestroy is called on request-scoped beans.
For the SaaS backend, TenantContext is the canonical request-scoped bean:
@Component
@Scope("request")
public class TenantContext {
private String tenantId;
private TenantPlan plan;
@PostConstruct
void resolve() {
// Extracted from request header X-Tenant-ID by a filter
}
}
Each request gets its own TenantContext with the correct tenant. But there is a problem: how does a singleton OrderService access it?
Session Scope
Session scope creates one instance per HTTP session. The instance is stored in HttpSession attributes. SessionScope extends the same AbstractRequestAttributesScope but returns RequestAttributes.SCOPE_SESSION:
public class SessionScope extends AbstractRequestAttributesScope {
@Override
protected int getScope() {
return RequestAttributes.SCOPE_SESSION;
}
}
Session-scoped beans survive across multiple requests from the same user session. In the SaaS backend, a UserPreferences bean might be session-scoped to cache locale and timezone settings:
@Component
@Scope("session")
public class UserPreferences {
private Locale locale;
private ZoneId timezone;
// Loaded once per session, reused across requests
}
Session scope shares the same injection problem as request scope. The scoped proxy mechanism solves both.
The Scoped Proxy Problem
This is the central issue. A singleton bean is created once at startup. A request-scoped bean is created per request. If you inject a request-scoped bean into a singleton, Spring resolves the dependency at startup. There is no active request at startup. The result depends on configuration, but it is never correct.
// BROKEN: TenantContext is request-scoped, injected into singleton
@Service
public class OrderService {
private final TenantContext tenantContext; // Injected at startup
public OrderService(TenantContext tenantContext) {
this.tenantContext = tenantContext;
// This TenantContext is either:
// 1. null (no request active at startup)
// 2. fixed to the first request's tenant (if lazy)
// 3. an exception (if RequestScope is strict)
}
public Order createOrder(OrderRequest request) {
String tenantId = tenantContext.getTenantId();
// Always returns the same tenantId, regardless of actual request
// Tenant A's orders go to Tenant B's database
}
}
This is a data isolation bug. In a multi-tenant system, it means cross-tenant data leaks.
Scoped Proxy: The CGLIB Solution
The fix is a scoped proxy. When proxyMode = ScopedProxyMode.TARGET_CLASS is set, Spring does not inject the actual request-scoped bean. It injects a CGLIB proxy (CH8). The proxy is a singleton. But every method call on the proxy delegates to the real bean looked up from the current scope.
// CORRECT: Scoped proxy delegates per invocation
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class TenantContext {
private String tenantId;
private TenantPlan plan;
// ...
}
The proxy class generated by CGLIB extends TenantContext (subclass-based proxy, as covered in CH8). When OrderService calls tenantContext.getTenantId(), the proxy intercepts the call:
- Proxy calls
RequestScope.get("tenantContext", objectFactory). RequestScopelooks up theTenantContextin the current request’s attributes.- If no instance exists for this request, the factory creates one.
- The proxy forwards
getTenantId()to the real instance. - The real instance returns the current request’s tenant ID.
Each request gets the correct TenantContext. The singleton OrderService never knows it is talking to a proxy.
@Service
public class OrderService {
private final TenantContext tenantContext; // CGLIB proxy, singleton-safe
public Order createOrder(OrderRequest request) {
// tenantContext.getTenantId() resolves per request
String tenantId = tenantContext.getTenantId();
// Correct tenant for every request
}
}
You can verify the proxy at runtime:
@PostConstruct
void inspectProxy() {
System.out.println(tenantContext.getClass().getName());
// com.saas.context.TenantContext$$SpringCGLIB$$0
System.out.println(tenantContext.getClass().getSuperclass().getName());
// com.saas.context.TenantContext
}
ScopedProxyMode.INTERFACES creates a JDK dynamic proxy instead. Use it when the bean implements an interface and you want to avoid the CGLIB subclass constraints (final class, final methods). In practice, TARGET_CLASS is used more often because most Spring beans are concrete classes without interfaces.
Custom Scopes
Spring allows registering custom scopes by implementing the Scope interface and registering it with the ConfigurableBeanFactory.
For the SaaS backend, a tenant scope creates one instance per tenant, shared across all requests for that tenant:
public class TenantScope implements Scope {
private final Map<String, Map<String, Object>> tenantBeans = new ConcurrentHashMap<>();
@Override
public Object get(String name, ObjectFactory<?> objectFactory) {
String tenantId = TenantContextHolder.getCurrentTenantId();
Map<String, Object> beans = tenantBeans.computeIfAbsent(
tenantId, k -> new ConcurrentHashMap<>());
return beans.computeIfAbsent(name, k -> objectFactory.getObject());
}
@Override
public Object remove(String name) {
String tenantId = TenantContextHolder.getCurrentTenantId();
Map<String, Object> beans = tenantBeans.get(tenantId);
return beans != null ? beans.remove(name) : null;
}
@Override
public void registerDestructionCallback(String name, Runnable callback) {
// Store callback, invoke on tenant eviction
}
@Override
public Object resolveContextualObject(String key) {
return null;
}
@Override
public String getConversationId() {
return TenantContextHolder.getCurrentTenantId();
}
}
Register it:
@Configuration
public class ScopeConfig {
@Bean
public static CustomScopeConfigurer tenantScopeConfigurer() {
CustomScopeConfigurer configurer = new CustomScopeConfigurer();
configurer.addScope("tenant", new TenantScope());
return configurer;
}
}
Use it:
@Component
@Scope(value = "tenant", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class TenantCacheManager {
private final Map<String, Object> cache = new ConcurrentHashMap<>();
// One cache per tenant, shared across all requests for that tenant
}
Scope Resolution in the Container
When AbstractBeanFactory.doGetBean() encounters a scope that is not singleton or prototype, it delegates to the registered Scope implementation:
String scopeName = mbd.getScope();
Scope scope = this.scopes.get(scopeName);
if (scope == null) {
throw new IllegalStateException("No Scope registered for scope name '" + scopeName + "'");
}
Object scopedInstance = scope.get(beanName, () -> {
beforePrototypeCreation(beanName);
try {
return createBean(beanName, mbd, args);
} finally {
afterPrototypeCreation(beanName);
}
});
The scopes map is a LinkedHashMap<String, Scope> in AbstractBeanFactory. Request and session scopes are registered by WebApplicationContextUtils.registerWebApplicationScopes() during web context initialization. Custom scopes are registered via CustomScopeConfigurer or direct calls to beanFactory.registerScope().
Choosing the Right Scope
| Scope | Instance Count | Destruction Managed | Use Case |
|---|---|---|---|
| Singleton | 1 per context | Yes (@PreDestroy) | Stateless services, repositories |
| Prototype | 1 per injection/getBean | No | Stateful, short-lived objects |
| Request | 1 per HTTP request | Yes | Per-request context (tenant, user) |
| Session | 1 per HTTP session | Yes | User preferences, CSRF tokens |
| Custom | Defined by Scope impl | Defined by Scope impl | Tenant-scoped caches, conversation scope |
The default is correct for 95% of beans. When it is not, the scoped proxy mechanism (CH24-S2) ensures that narrow-scoped beans can be safely used from wider-scoped consumers. The proxy is the bridge between scope lifetimes. Without it, scope mismatches produce silent data corruption bugs that are nearly impossible to reproduce in testing because test contexts typically process one request at a time.