Spring Bean Scopes and Lifecycle

Every bean in the Spring container has a scope (how many instances exist and for how long) and a lifecycle (what happens when it’s created and destroyed). Understanding these prevents subtle bugs and lets you optimize resource usage.

Bean Scopes Overview

ScopeInstancesAvailable in
singletonOne per ApplicationContextAll apps
prototypeNew instance every timeAll apps
requestOne per HTTP requestWeb apps
sessionOne per HTTP sessionWeb apps
applicationOne per ServletContextWeb apps

Singleton (Default)

By default, every Spring bean is a singleton — one instance per ApplicationContext.

@Service   // singleton by default
public class OrderService {
    // Spring creates exactly one OrderService for the entire app
}
@Service
public class MyService {

    // Both injections point to the SAME OrderService instance
    private final OrderService a;
    private final OrderService b;

    public MyService(OrderService a, OrderService b) {
        this.a = a;
        this.b = b;
        assert a == b; // true — same object
    }
}

Singletons must be stateless (or thread-safe). If your OrderService holds a currentOrder field, multiple concurrent requests will corrupt each other’s state.

// WRONG — not thread-safe
@Service
public class OrderService {
    private Order currentOrder;  // shared across all threads!

    public void process(Order order) {
        this.currentOrder = order;  // race condition
    }
}

// CORRECT — stateless service
@Service
public class OrderService {

    public void process(Order order) {
        // order is a local variable — no shared state
    }
}

Prototype

A new instance is created every time the bean is requested from the container.

@Component
@Scope("prototype")
public class OrderBuilder {

    private final List<OrderItem> items = new ArrayList<>();

    public OrderBuilder addItem(OrderItem item) {
        items.add(item);
        return this;
    }

    public Order build() {
        return new Order(items);
    }
}
@Service
public class OrderService {

    private final ApplicationContext ctx;

    public OrderService(ApplicationContext ctx) {
        this.ctx = ctx;
    }

    public Order buildOrder(List<OrderItem> items) {
        // Gets a fresh OrderBuilder each time — no shared state
        OrderBuilder builder = ctx.getBean(OrderBuilder.class);
        items.forEach(builder::addItem);
        return builder.build();
    }
}

The prototype injection trap: if you inject a prototype bean into a singleton, you get the same prototype instance for the singleton’s lifetime. The singleton captures it at construction time.

@Service
public class OrderService {

    @Autowired
    private OrderBuilder builder;  // WRONG: gets ONE instance forever, even though prototype
}

To get a new prototype each time from a singleton, use ApplicationContext.getBean() (as above) or the ObjectProvider API:

@Service
public class OrderService {

    private final ObjectProvider<OrderBuilder> builderProvider;

    public OrderService(ObjectProvider<OrderBuilder> builderProvider) {
        this.builderProvider = builderProvider;
    }

    public Order buildOrder(List<OrderItem> items) {
        OrderBuilder builder = builderProvider.getObject(); // new instance each call
        items.forEach(builder::addItem);
        return builder.build();
    }
}

Web Scopes

Web scopes are only available in web applications (embedded Tomcat running).

Request Scope

One instance per HTTP request. Created when the request starts, destroyed when it ends.

@Component
@RequestScope   // shorthand for @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {

    private String traceId;
    private String authenticatedUserId;
    private long startTime = System.currentTimeMillis();

    // getters and setters
}
@Service
public class OrderService {

    private final RequestContext requestCtx;

    public OrderService(RequestContext requestCtx) {
        this.requestCtx = requestCtx;
    }

    public Order createOrder(CreateOrderRequest req) {
        log.info("Creating order for user {} (trace: {})",
            requestCtx.getAuthenticatedUserId(),
            requestCtx.getTraceId());
        // ...
    }
}

How it works: Spring injects a scoped proxy. The proxy looks like RequestContext but delegates to the real bean stored in the current request’s scope. When a new request comes in, the proxy points to a fresh instance.

Session Scope

One instance per HTTP session. Good for user-specific state across requests.

@Component
@SessionScope
public class ShoppingCart {

    private final List<CartItem> items = new ArrayList<>();

    public void add(CartItem item) { items.add(item); }
    public void remove(CartItem item) { items.remove(item); }
    public List<CartItem> getItems() { return Collections.unmodifiableList(items); }
    public void clear() { items.clear(); }
}

The ShoppingCart persists across multiple requests from the same user session, then is destroyed when the session expires.

The Bean Lifecycle

When Spring starts up, each singleton bean goes through this lifecycle:

1. Instantiation
   └─ Spring calls the constructor

2. Dependency Injection
   └─ Spring injects all dependencies

3. Post-Processing (BeanPostProcessor: before init)
   └─ Framework hooks (e.g., @Autowired processing, proxy creation)

4. Initialization
   ├─ @PostConstruct method called
   ├─ InitializingBean.afterPropertiesSet() called
   └─ @Bean(initMethod = "...") called

5. Post-Processing (BeanPostProcessor: after init)
   └─ AOP proxy wrapping (for @Transactional, @Cacheable, etc.)

6. Bean is ready — injected into other beans and used by the application

7. Destruction (on context shutdown)
   ├─ @PreDestroy method called
   ├─ DisposableBean.destroy() called
   └─ @Bean(destroyMethod = "...") called

@PostConstruct — Initialization Hook

Called after injection is complete. Use it for setup that requires all dependencies to be present.

@Service
public class ProductCatalogService {

    private final ProductRepository repository;
    private Map<String, Product> cache;

    public ProductCatalogService(ProductRepository repository) {
        this.repository = repository;
    }

    @PostConstruct
    public void loadCache() {
        // repository is already injected — safe to use here
        cache = repository.findAll()
            .stream()
            .collect(Collectors.toMap(Product::getSku, p -> p));
        log.info("Product cache loaded: {} products", cache.size());
    }
}

Why not put this in the constructor? The constructor runs before injection — you can’t call repository.findAll() from the constructor because repository isn’t set yet.

// WRONG — repository is null in the constructor
public ProductCatalogService(ProductRepository repository) {
    this.repository = repository;
    cache = repository.findAll(); // NullPointerException if field injection
    // (safe for constructor injection, but the pattern below is cleaner)
}

// RIGHT — use @PostConstruct
@PostConstruct
public void init() {
    cache = repository.findAll();
}

@PreDestroy — Cleanup Hook

Called when the application context is shutting down. Use it to release resources.

@Component
public class ConnectionPoolManager {

    private final ExecutorService threadPool;
    private final ScheduledExecutorService scheduler;

    public ConnectionPoolManager() {
        this.threadPool = Executors.newFixedThreadPool(10);
        this.scheduler = Executors.newScheduledThreadPool(2);
    }

    @PostConstruct
    public void start() {
        scheduler.scheduleAtFixedRate(this::healthCheck, 0, 30, TimeUnit.SECONDS);
    }

    @PreDestroy
    public void shutdown() {
        log.info("Shutting down thread pool...");
        scheduler.shutdown();
        threadPool.shutdown();
        try {
            if (!threadPool.awaitTermination(30, TimeUnit.SECONDS)) {
                threadPool.shutdownNow();
            }
        } catch (InterruptedException e) {
            threadPool.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }
}

For Spring Boot apps, enable graceful shutdown to give beans time to finish:

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

Lifecycle via @Bean

When you declare beans in @Configuration, specify init and destroy methods:

@Configuration
public class InfraConfig {

    @Bean(initMethod = "connect", destroyMethod = "disconnect")
    public RedisClient redisClient() {
        RedisClient client = new RedisClient();
        client.setHost("localhost");
        client.setPort(6379);
        return client;
        // Spring calls client.connect() after creation
        // Spring calls client.disconnect() on shutdown
    }
}

@Lazy — Deferred Bean Creation

By default, all singleton beans are created at startup. @Lazy defers creation until the bean is first needed.

@Service
@Lazy
public class ReportGenerationService {
    // Expensive to construct — only create when first used
    public ReportGenerationService() {
        log.info("Loading report templates...");  // won't run at startup
    }
}

Or lazy-inject a dependency:

@Service
public class OrderService {

    private final OrderRepository repository;

    @Lazy
    private final ReportService reportService;  // created on first use

    public OrderService(OrderRepository repository,
                        @Lazy ReportService reportService) {
        this.repository = repository;
        this.reportService = reportService;
    }
}

Use @Lazy when: the bean is expensive to initialize and rarely used. Don’t use it everywhere — it hides startup errors and makes initialization timing unpredictable.

Practical: A Complete Lifecycle Example

Here’s a MetricsExporter that demonstrates the full lifecycle:

@Component
public class MetricsExporter {

    private final MeterRegistry registry;
    private final OrderRepository repository;
    private ScheduledExecutorService scheduler;
    private Gauge activeOrdersGauge;

    public MetricsExporter(MeterRegistry registry, OrderRepository repository) {
        this.registry = registry;
        this.repository = repository;
        // Can't call repository methods here — might not be fully initialized
    }

    @PostConstruct
    public void initialize() {
        // Both registry and repository are fully injected
        activeOrdersGauge = Gauge.builder("orders.active",
                repository, repo -> repo.countByStatus(OrderStatus.ACTIVE))
            .description("Number of active orders")
            .register(registry);

        scheduler = Executors.newSingleThreadScheduledExecutor();
        scheduler.scheduleAtFixedRate(
            this::exportSnapshot, 0, 1, TimeUnit.MINUTES);

        log.info("MetricsExporter initialized — exporting every minute");
    }

    private void exportSnapshot() {
        // runs every minute
    }

    @PreDestroy
    public void teardown() {
        log.info("MetricsExporter shutting down...");
        scheduler.shutdown();
        registry.remove(activeOrdersGauge);
    }
}

Common Scope Mistakes

Mistake 1: Storing request-specific state in a singleton

// WRONG
@Service  // singleton
public class OrderService {
    private String currentUserId;  // shared! race condition
}

// RIGHT — use RequestScope bean or pass as parameter
@Service
public class OrderService {
    public Order create(CreateOrderRequest req, String userId) {
        // userId passed in — no shared state
    }
}

Mistake 2: Injecting a short-lived bean into a long-lived bean without a proxy

// WRONG — sessionBean captured at singleton construction, never refreshed
@Service  // singleton
public class CartService {
    @Autowired
    private ShoppingCart cart;  // @SessionScope without proxy
}

// RIGHT — @SessionScope adds a proxy by default, so this works
@SessionScope  // includes proxyMode = ScopedProxyMode.TARGET_CLASS
public class ShoppingCart { ... }

Mistake 3: Doing work in the constructor that requires injection

// WRONG
public class CacheService {
    public CacheService(ProductRepository repo) {
        this.cache = repo.findAll();  // fine for constructor injection, but confusing
    }
}

// RIGHT — clear separation of construction and initialization
public class CacheService {
    public CacheService(ProductRepository repo) {
        this.repo = repo;  // just assign
    }

    @PostConstruct
    void load() {
        this.cache = repo.findAll();  // clear: this runs after construction
    }
}

What You’ve Learned

  • singleton is the default scope — one instance per ApplicationContext, must be stateless
  • prototype creates a new instance every request — use ObjectProvider to get prototypes from singletons
  • Web scopes (request, session) use scoped proxies to give singleton beans access to short-lived state
  • @PostConstruct runs after all dependencies are injected — use it for initialization that needs dependencies
  • @PreDestroy runs on context shutdown — use it for resource cleanup
  • @Lazy defers bean creation until first use — use it for expensive, rarely-used beans

This completes Part 1: Getting Started. You now understand the core mechanics of Spring Boot. In Part 2, we build the REST API layer on top of this foundation.

Next: Article 8 — Building Your First REST API with Spring Boot — controllers, request mapping, response entities, and HTTP status codes.