← Back to Blog

Your Spring Bean Is Not What You Think It Is

Spring's default bean scope is singleton. The bugs appear when a service holds mutable state, a scoped bean is misused, or ThreadLocal cleanup is skipped.

The feature was simple. A service collected validation errors during request processing, bundled them into a response, and returned the list. It worked in every test and every local run. In production, under any meaningful concurrency, it stopped working correctly. Validation errors from one request appeared in another request's response. The list grew across requests and was never fully cleared. Two users on different sessions were sharing the same error accumulator.

The @Service had a field: private List<String> errors = new ArrayList<>(). It was added the same way you would add any field to a class. The problem is that in Spring, a @Service is a singleton. There is one instance, shared across every thread that touches it, including that errors list.

Everything Is a Singleton by Default

Spring's default bean scope is singleton: one instance per application context, shared across every thread that calls into it. This is the right default. Stateless services, repositories, and components do not need to be reinstantiated per request. The overhead would be enormous.

The assumption built into that default is that the beans are stateless. No instance fields that get written during request processing. No accumulated state between calls. Nothing in Java or Spring prevents you from adding a mutable field to a @Service. It compiles. Tests pass. The IDE stays quiet. The bug shows up only when two requests arrive at the same time.

Mutable State in a Singleton

Here is the pattern that causes it:

@Service
public class OrderValidator {
    private List<String> errors = new ArrayList<>();

    public List<String> validate(Order order) {
        errors.clear();
        if (order.getTotal() < 0) {
            errors.add("Total cannot be negative");
        }
        if (order.getItems().isEmpty()) {
            errors.add("Order must have at least one item");
        }
        return errors;
    }
}

This looks reasonable in isolation. The list is cleared at the start of each call. The problem is that clear() and the subsequent adds are not atomic. Thread A clears the list and starts adding. Thread B clears the list before Thread A has finished. Thread A's errors disappear. Thread B adds its errors. Thread A returns an empty or wrong list.

The fix is not synchronization. The fix is removing the field. State that belongs to a single request belongs in local variables, not on the service.

@Service
public class OrderValidator {
    public List<String> validate(Order order) {
        List<String> errors = new ArrayList<>();
        if (order.getTotal() < 0) {
            errors.add("Total cannot be negative");
        }
        if (order.getItems().isEmpty()) {
            errors.add("Order must have at least one item");
        }
        return errors;
    }
}

Local variables are stack-allocated, thread-local by definition, and invisible to other threads. They do not need synchronization. If you find yourself reaching for an instance field in a Spring bean, ask whether that state could be a local variable or a method parameter. The answer is almost always yes.

Request-Scoped Bean in a Singleton

Request scope exists for beans that should be created fresh per HTTP request: the current user context, per-request caches, request metadata. The problem is injecting one into a singleton.

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {
    private String userId;
    public String getUserId() { return userId; }
    public void setUserId(String id) { this.userId = id; }
}

@Service
public class AuditService {
    @Autowired
    private RequestContext requestContext;

    public void log(String action) {
        String user = requestContext.getUserId();
        // ...
    }
}

Spring handles this through a scoped proxy. The AuditService does not hold a direct reference to a RequestContext instance. It holds a proxy. Every call to requestContext.getUserId() goes through that proxy, which looks up the real RequestContext for the current HTTP request thread.

This works correctly in a servlet thread. It does not work in @Async methods that run on a different thread pool, @Scheduled tasks, or any manually created background thread. In those contexts, there is no HTTP request in scope. The proxy has nothing to look up and throws IllegalStateException: No thread-bound request found. The fix is to extract what you need from the request-scoped bean before handing off to a background thread, and pass it as a method parameter.

If proxyMode is omitted from the @Scope annotation, Spring injects the actual instance from the first request that triggers injection, which is effectively a singleton leak. Always specify proxyMode on request-scoped beans that get injected into singletons.

Prototype That Behaves Like a Singleton

Prototype-scoped beans are created fresh every time they are requested from the application context. Intended use: beans with mutable state that must not be shared between callers, or objects that carry per-operation context.

@Component
@Scope("prototype")
public class ReportBuilder {
    private List<ReportSection> sections = new ArrayList<>();

    public void addSection(ReportSection section) {
        sections.add(section);
    }

    public Report build() {
        return new Report(sections);
    }
}

The expectation is that each caller gets a fresh ReportBuilder. The reality depends on how it is injected.

@Service
public class ReportService {
    @Autowired
    private ReportBuilder reportBuilder; // wrong

    public Report generate(List<ReportSection> sections) {
        sections.forEach(reportBuilder::addSection);
        return reportBuilder.build();
    }
}

Spring injects reportBuilder once, when ReportService is created. A prototype gets created once and stored. Every call to generate() uses the same ReportBuilder, which accumulates sections across requests. So @Scope("prototype") does nothing useful here.

To fix this, fetch a new instance from the container on each use:

@Service
public class ReportService {
    @Autowired
    private ApplicationContext context;

    public Report generate(List<ReportSection> sections) {
        ReportBuilder builder = context.getBean(ReportBuilder.class);
        sections.forEach(builder::addSection);
        return builder.build();
    }
}

Or use Spring's @Lookup to declare a factory method that Spring overrides at runtime:

@Service
public abstract class ReportService {
    @Lookup
    protected abstract ReportBuilder newReportBuilder();

    public Report generate(List<ReportSection> sections) {
        ReportBuilder builder = newReportBuilder();
        sections.forEach(builder::addSection);
        return builder.build();
    }
}

@Lookup requires the class to be non-final and the method to be non-private, because Spring uses CGLIB to subclass and override the method. An abstract method version makes the intent explicit: this is a factory, not an implementation.

ThreadLocal That Leaks Across Requests

ThreadLocal variables are visible only to the thread that sets them, which makes them useful for passing context through a call stack without method parameters. The pattern looks like this:

public class RequestContextHolder {
    private static final ThreadLocal<String> currentUser = new ThreadLocal<>();

    public static void set(String userId) { currentUser.set(userId); }
    public static String get() { return currentUser.get(); }
    public static void clear() { currentUser.remove(); }
}

A servlet filter sets it at the start of the request and clears it at the end:

@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
        throws IOException, ServletException {
    RequestContextHolder.set(extractUser(req));
    chain.doFilter(req, res);
    RequestContextHolder.clear(); // only runs if no exception is thrown
}

The last line is the bug. If chain.doFilter() throws, the clear() call is skipped. The thread goes back to the pool with the previous user's context still set. The next request that picks up that thread inherits it. Depending on what the context contains, this ranges from incorrect audit logs to authorization bypass.

The fix is try/finally:

@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
        throws IOException, ServletException {
    RequestContextHolder.set(extractUser(req));
    try {
        chain.doFilter(req, res);
    } finally {
        RequestContextHolder.clear();
    }
}

finally runs regardless of whether the call succeeds or throws. This is the only safe pattern for ThreadLocal cleanup in a filter. Spring's own RequestContextFilter and SecurityContextPersistenceFilter both use it. Custom ThreadLocal usage usually does not.

What to Check Right Now

Grep your codebase for non-final instance fields in classes annotated with @Service, @Component, @Repository, or @Controller. Any field that gets written during request processing is a candidate for a concurrency bug.

Then look at @Scope("prototype") beans injected via @Autowired into classes that are not also prototype-scoped. Every match is a case where the prototype is silently behaving like a singleton.

Finally, find every ThreadLocal field. For each one, find every place it is set and verify there is a finally block that clears it. If the set and clear are in different methods, the cleanup path is probably not safe.

The tests will not catch any of these. Unit tests run single-threaded. Integration tests usually do too. These bugs live at the intersection of concurrent load and shared state, and that intersection only exists in production.

Share
X LinkedIn HN
UI

Umur Inan

Principal Software Engineer

Backend engineer focused on JVM systems, distributed architecture, and the failure modes that only show up in production. I write about what I learn building and breaking things at scale.

👁 0 6 min read

Comments (0)