Up to now we have treated beans as objects that simply appear and work. This lesson opens that box. We will see what exactly a bean is and what the container that looks after them is; how many instances Spring creates of each one and why that decision —the scope— shapes how you must write your classes; what happens, step by step, between the constructor being called and the bean becoming available; how to hook code onto the moment of birth and the moment of death; and what the extension point is, the BeanPostProcessor, that transactions, caching and practically all of Spring's apparent magic rest on. In CicloUrbana we will build an AvailabilityCache that preloads at startup and releases resources on shutdown, and we will reason through why InMemoryStationRepository had to use a ConcurrentHashMap.

Contents

  1. What a bean is and what the container is
  2. BeanFactory versus ApplicationContext
  3. Spring's scopes
  4. Singleton scope and the danger of mutable state
  5. A prototype inside a singleton: the classic problem
  6. The complete lifecycle of a bean
  7. @PostConstruct and @PreDestroy in CicloUrbana
  8. Lazy initialisation
  9. BeanPostProcessor: the extension point
  10. Common Mistakes and Tips
  11. Exercises

  1. What a bean is and what the container is

A bean is, quite simply, a Java object whose lifecycle Spring manages: the container instantiates it, injects its dependencies, initialises it, keeps it available for as long as it is needed and destroys it on shutdown. There is no interface to implement and no base class to extend; the only thing that makes an object a bean is being registered in the container.

From that follows a distinction worth being clear about:

Bean Ordinary object
Who creates it The container You, with new
How many exist As many as its scope dictates As many as the new calls you write
Receives injection Yes No
@Transactional, @Cacheable, @Async work on it Yes (via proxy) No
Examples in CicloUrbana StationService, StandardFare, StationController Station, Rental, a BigDecimal

That last row explains a very frequent mistake: annotating a method of an object created with new with @Transactional and not understanding why no transaction opens. No bean means no proxy, and no proxy means no added behaviour.

The container is the object that holds the bean registry, knows how to build them and resolves their dependencies. In Spring there are two levels of it.

  1. BeanFactory versus ApplicationContext

flowchart TD
    BF["BeanFactory<br/>getBean(), containsBean()<br/>basic lifecycle"] --> AC["ApplicationContext<br/>extends BeanFactory"]
    AC --> F1["Event publishing<br/>ApplicationEventPublisher"]
    AC --> F2["i18n message resolution<br/>MessageSource"]
    AC --> F3["Resource access<br/>ResourceLoader"]
    AC --> F4["Environment and properties"]
    AC --> F5["Automatic processing of<br/>BeanPostProcessor and BeanFactoryPostProcessor"]
    AC --> F6["Eager creation of singletons<br/>at startup"]
BeanFactory ApplicationContext
Purpose Minimal container: create and serve beans Full application container
Singleton creation Lazy (when requested) Eager, at startup
Events, i18n, resources, Environment No Yes
Automatic registration of post-processors Manual Automatic
Use in Spring Boot Internal The one you always use

In Spring Boot the real container is always an ApplicationContext (for CicloUrbana specifically, an AnnotationConfigServletWebServerApplicationContext, as we saw in the startup log of lesson 01-05). BeanFactory is the underlying interface, useful to know because a lot of error messages mention it.

One detail with practical consequences: the ApplicationContext creates every singleton during startup, not the first time they are used. That is what lets a badly configured bean blow up the startup instead of a client's first request. It is a deliberate design decision: fail early and loudly.

  1. Spring's scopes

The scope answers the question: how many instances of this bean exist and how long do they live?

Scope Instances Lifetime Available in
singleton One per container The whole application lifecycle Always (the default)
prototype One per request to the container Until the garbage collector reclaims it Always
request One per HTTP request The HTTP request Web applications only
session One per HTTP session The user's session Web applications only
application One per ServletContext The whole web application Web applications only
websocket One per WebSocket session The WebSocket session With WebSocket

It is declared with @Scope:

package com.ciclourbana.rentals;

import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

/**
 * Accumulates the events of a demand simulation. Each simulation
 * needs its own stateful accumulator, so it CANNOT be a singleton.
 */
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)   // same as @Scope("prototype")
public class SimulationSession {

    private final Instant startedAt = Instant.now();
    private final List<String> events = new ArrayList<>();

    public void record(String event) {
        events.add(event);
    }

    public List<String> events() {
        return List.copyOf(events);
    }

    public Instant startedAt() {
        return startedAt;
    }
}

Web scopes are declared the same way, although they have more readable shortcuts:

@Component
@RequestScope    // = @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext { /* data for the HTTP request in flight */ }

@Component
@SessionScope    // = @Scope(value = "session", proxyMode = ...)
public class TopUpCart { /* per-user state, across requests */ }

An important warning about these two: most REST APIs should not use them. A REST service is, by definition, stateless; storing information in a session bean breaks that property and complicates horizontal scaling (two instances behind a load balancer do not share a session). In CicloUrbana we will not use session: the user's state will travel in the JWT token we build in module 5.

And a critical difference between singleton and prototype that surprises a lot of people:

flowchart LR
    subgraph S["singleton"]
        C1["Container"] -->|creates, initialises,<br/>destroys| B1["1 instance"]
    end
    subgraph P["prototype"]
        C2["Container"] -->|creates and initialises| B2["instance 1"]
        C2 -->|creates and initialises| B3["instance 2"]
        C2 -.->|"does NOT destroy it:<br/>@PreDestroy is not called"| B2
    end

Spring does not manage the destruction of prototype beans. It creates them, injects them and forgets them. Their @PreDestroy methods never run. If a prototype bean opens a resource (a file, a connection), you are the one who has to close it.

  1. Singleton scope and the danger of mutable state

The default scope is singleton: a single instance shared by the whole container. And here comes the most important consequence of this entire lesson: in a web application, that single instance is used by every HTTP request at once, each one on a different Tomcat thread.

That is why the golden rule is: beans must not hold mutable state.

An example of what not to do, written on top of our StationService:

@Service
public class StationService {

    private final StationRepository stationRepository;

    // DANGER! Mutable field in a singleton shared by every thread
    private int lastQueriedId;
    private List<Station> partialResult = new ArrayList<>();

    public List<Station> findByMinimumCapacity(int minimum) {
        partialResult.clear();                          // thread A clears...
        for (Station s : stationRepository.findAll()) {
            if (s.capacity() >= minimum) {
                partialResult.add(s);                   // ...while thread B adds
            }
        }
        return partialResult;                           // unpredictable result
    }
}

With a single request it works perfectly. With two simultaneous requests it returns mixed-up results, or throws a ConcurrentModificationException, or —worst of all— returns another user's data. And it is a bug that does not show up in development: it only appears in production, under load, intermittently. It is one of the most expensive classes of bug to diagnose.

The correct version uses only local variables, which live on each thread's stack and are not shared:

@Service
public class StationService {

    private final StationRepository stationRepository;   // final and immutable

    public StationService(StationRepository stationRepository) {
        this.stationRepository = stationRepository;
    }

    public List<Station> findByMinimumCapacity(int minimum) {
        // All the state is local to the thread: safe by construction
        return stationRepository.findAll().stream()
                .filter(s -> s.capacity() >= minimum)
                .sorted(Comparator.comparing(Station::name))
                .toList();
    }
}

The three legitimate ways of holding state in a singleton:

Form Example in CicloUrbana
final fields holding immutable objects private final StationRepository repository;
Concurrent data structures private final Map<Long, Station> byId = new ConcurrentHashMap<>();
Atomic counters private final AtomicLong nextId = new AtomicLong(0);

Now it is clear why in lesson 02-01 we wrote InMemoryStationRepository with ConcurrentHashMap and AtomicLong instead of HashMap and long: it is a singleton, and several threads touch its state. With a plain HashMap, two simultaneous insertions can corrupt the internal structure and cause infinite loops in later reads.

If you genuinely need per-user or per-request state, the options are: pass it as a parameter (almost always the best), use a prototype bean, or —in the case of a website with a session— a session bean.

  1. A prototype inside a singleton: the classic problem

This is the most quoted mistake around Spring's container, and it is worth understanding well.

@Service                                    // singleton
public class DemandSimulator {

    private final SimulationSession session; // prototype

    public DemandSimulator(SimulationSession session) {
        this.session = session;             // injected ONCE, at startup
    }

    public void simulate() {
        session.record("simulation run");   // always the SAME session!
    }
}

The injection happens exactly once, when the singleton is built. The prototype instance received stays frozen in the field for the whole life of the application. The prototype scope is lost entirely: in practice, that bean behaves like a singleton.

Solution 1: ObjectProvider (the recommended one)

We met it in lesson 02-02 as a way of expressing optional dependencies. Its other virtue is lazy resolution on every call:

package com.ciclourbana.rentals;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;

@Service
public class DemandSimulator {

    private final ObjectProvider<SimulationSession> sessionProvider;

    public DemandSimulator(ObjectProvider<SimulationSession> sessionProvider) {
        this.sessionProvider = sessionProvider;
    }

    public void simulate(String scenarioName) {
        // getObject() asks the container for a NEW instance on every call
        SimulationSession session = sessionProvider.getObject();
        session.record("scenario: " + scenarioName);
        session.record("stations evaluated: 4");
        // The session dies when it goes out of scope: Spring does not destroy it
    }
}

Solution 2: @Lookup

Spring can override an abstract method so that it returns a fresh instance. It does so by generating a subclass with CGLIB:

@Service
public abstract class DemandSimulator {

    public void simulate(String scenarioName) {
        SimulationSession session = newSession();   // a new instance every time
        session.record("scenario: " + scenarioName);
    }

    /** Spring implements this method for us. */
    @Lookup
    protected abstract SimulationSession newSession();
}

Solution 3: a scoped proxy

@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class SimulationSession { /* ... */ }

With proxyMode, what gets injected into the singleton is not the instance but a proxy that, on every method invocation, resolves the right instance. It is the mandatory option for the request and session scopes (which is why @RequestScope and @SessionScope include it out of the box): a singleton is built at startup, when there is no live HTTP request yet from which to obtain the bean.

Side by side:

Solution Readability When to use it
ObjectProvider Explicit: you can see an instance being asked for Preferred in your own code
@Lookup Elegant but demands an abstract class Legacy cases or APIs that do not accept ObjectProvider
proxyMode Transparent, but hides what is going on Mandatory for request/session

  1. The complete lifecycle of a bean

This is the full journey of a singleton bean, from the moment Spring decides to create it until the application shuts down:

flowchart TD
    A["1. Instantiation<br/>the constructor is invoked<br/>(constructor injection)"] --> B["2. Setter and field injection<br/>@Autowired, @Value"]
    B --> C["3. *Aware* interfaces<br/>BeanNameAware.setBeanName()<br/>BeanFactoryAware<br/>ApplicationContextAware"]
    C --> D["4. BeanPostProcessor<br/>postProcessBeforeInitialization()"]
    D --> E["5. @PostConstruct"]
    E --> F["6. InitializingBean.afterPropertiesSet()"]
    F --> G["7. init-method<br/>@Bean(initMethod = ...)"]
    G --> H["8. BeanPostProcessor<br/>postProcessAfterInitialization()<br/>← AOP proxies wrap it here"]
    H --> I["BEAN READY FOR USE"]
    I --> J["9. @PreDestroy"]
    J --> K["10. DisposableBean.destroy()"]
    K --> L["11. destroy-method<br/>@Bean(destroyMethod = ...)"]
    L --> M["Bean destroyed"]

The three blocks of three steps each deserve a comment.

Initialisation (5, 6, 7). There are three ways of running code when a bean is born, and they run in that order. They are redundant; always use @PostConstruct unless you have a reason not to:

Mechanism Coupling to Spring Recommendation
@PostConstruct (jakarta.annotation) None: it is a Jakarta EE standard Use it
InitializingBean.afterPropertiesSet() High: forces you to implement a Spring interface Avoid it
@Bean(initMethod = "startUp") None inside the class For third-party classes you cannot annotate

Destruction (9, 10, 11). The same scheme with @PreDestroy. With two warnings: they only run if the context is closed in an orderly fashion (remember server.shutdown=graceful from lesson 01-05; a kill -9 runs nothing), and they never run on prototype beans.

Post-processing (4 and 8). This is where nearly everything interesting happens, and that is why it has a section of its own further down.

A note on the Aware interfaces: they let the bean receive infrastructure from the container.

@Component
public class BeanDiagnostics implements BeanNameAware, ApplicationContextAware {

    private String beanName;
    private ApplicationContext context;

    @Override
    public void setBeanName(String name) {
        this.beanName = name;        // "beanDiagnostics"
    }

    @Override
    public void setApplicationContext(ApplicationContext context) {
        this.context = context;
    }
}

Use them sparingly: they couple your class to Spring. ApplicationContextAware in particular tends to be a service locator smell; injecting what you need is almost always preferable.

  1. @PostConstruct and @PreDestroy in CicloUrbana

Let us build something useful: a cache of network availability that is precomputed at startup and releases its resources on shutdown.

Be clear about one thing before starting: this is a manual cache, written by hand to illustrate the lifecycle. Spring's declarative cache, with @Cacheable and @CacheEvict, is a different thing and is covered in lesson 09-02.

package com.ciclourbana.stations;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

/**
 * In-memory cache of the free docks per station.
 *
 * It precomputes the values at startup (@PostConstruct) and dumps
 * statistics on shutdown (@PreDestroy). It is a manual cache: the
 * declarative cache with @Cacheable is covered in lesson 09-02.
 */
@Component
public class AvailabilityCache {

    private static final Logger log = LoggerFactory.getLogger(AvailabilityCache.class);

    private final StationRepository stationRepository;

    // ConcurrentHashMap: the bean is a singleton and several threads read it
    private final Map<Long, Integer> freeDocks = new ConcurrentHashMap<>();
    private final AtomicLong hits = new AtomicLong();
    private final AtomicLong misses = new AtomicLong();

    private Instant loadedAt;

    public AvailabilityCache(StationRepository stationRepository) {
        this.stationRepository = stationRepository;
        // CAREFUL: we do NOT preload here. The constructor only assigns dependencies.
        log.debug("AvailabilityCache constructed (no data yet)");
    }

    /**
     * Runs after every dependency has been injected.
     * It is the right place for heavy initialisation work.
     */
    @PostConstruct
    public void preload() {
        Instant start = Instant.now();

        stationRepository.findAll().forEach(station ->
                // Simulation: at startup we assume half the docks are occupied
                freeDocks.put(station.id(), station.capacity() / 2));

        this.loadedAt = Instant.now();
        log.info("Availability cache preloaded with {} stations in {} ms",
                freeDocks.size(),
                Duration.between(start, loadedAt).toMillis());
    }

    public int freeDocks(Long stationId) {
        Integer value = freeDocks.get(stationId);
        if (value == null) {
            misses.incrementAndGet();
            return recalculate(stationId);
        }
        hits.incrementAndGet();
        return value;
    }

    public void update(Long stationId, int free) {
        freeDocks.put(stationId, free);
    }

    private int recalculate(Long stationId) {
        int free = stationRepository.findById(stationId)
                .map(station -> station.capacity() / 2)
                .orElse(0);
        freeDocks.put(stationId, free);
        return free;
    }

    /**
     * Runs when the context is closed in an orderly fashion.
     * This is where connections, threads or open files would be released.
     */
    @PreDestroy
    public void release() {
        log.info("Closing the availability cache after {} in service",
                Duration.between(loadedAt, Instant.now()));
        log.info("Statistics: {} hits, {} misses ({}% hit rate)",
                hits.get(), misses.get(), hitRate());
        freeDocks.clear();
    }

    private long hitRate() {
        long total = hits.get() + misses.get();
        return total == 0 ? 0 : (hits.get() * 100) / total;
    }
}

One ordering detail that causes real problems: this bean's @PostConstruct runs before the CommandLineRunners do. And our DemoStationLoader is a CommandLineRunner. The result: when preload() runs, the repository is still empty and the cache is born with no data.

sequenceDiagram
    participant SA as SpringApplication
    participant Ctx as ApplicationContext
    participant C as AvailabilityCache
    participant R as DemoStationLoader

    SA->>Ctx: refresh()
    Ctx->>C: constructor
    Ctx->>C: @PostConstruct preload()
    Note over C: Empty repository:<br/>0 stations cached
    Ctx-->>SA: context ready
    SA->>R: run() — loads the 4 stations
    Note over R: Now there is data,<br/>but the cache is already populated

This is exactly the kind of temporal dependency you need to learn to spot. Three ways of resolving it:

  1. Preload in an ApplicationReadyEvent instead of in @PostConstruct: it is published after the runners.
  2. Populate the repository beforehand, in the @PostConstruct of a bean the cache depends on.
  3. Accept an empty cache and let it fill on demand through recalculate(), which is what our implementation already does.

For CicloUrbana we choose the first one, because it is the most explicit:

@Component
public class CacheWarmer {

    private final AvailabilityCache cache;
    private final StationRepository repository;

    public CacheWarmer(AvailabilityCache cache, StationRepository repository) {
        this.cache = cache;
        this.repository = repository;
    }

    /** ApplicationReadyEvent is published AFTER the CommandLineRunners. */
    @EventListener(ApplicationReadyEvent.class)
    public void warmUp() {
        repository.findAll().forEach(s ->
                cache.update(s.id(), s.capacity() / 2));
    }
}

Seeing it in action

./mvnw spring-boot:run

At startup you will see the @PostConstruct, and on stopping with Ctrl+C you will see the @PreDestroy:

c.c.stations.AvailabilityCache : Availability cache preloaded with 0 stations in 1 ms
c.c.s.DemoStationLoader        : Loaded 4 stations, 108 docks in total
...
c.c.stations.AvailabilityCache : Closing the availability cache after PT2M14S in service
c.c.stations.AvailabilityCache : Statistics: 12 hits, 4 misses (75% hit rate)

  1. Lazy initialisation

By default, the ApplicationContext creates every singleton during startup. This can be changed bean by bean:

@Component
@Lazy   // not created until someone asks for it for the first time
public class MonthlyReportGenerator { /* expensive process, used once a month */ }

Or globally:

# src/main/resources/application.properties
spring.main.lazy-initialization=true

Its trade-offs, in a table:

Aspect With eager creation (the default) With lazy initialisation
Startup time Longer Shorter
Detection of configuration errors At startup On the first request
Latency of the first request Low High (the chain of beans gets built)
Memory at startup All that is needed Only that of the beans in use
Recommended use Production Development, one-off tests

The trade-off is a serious one: with lazy initialisation, a badly configured bean does not blow up the startup; it blows up when a real user touches that code path. You lose exactly the property that makes Spring Boot useful in production.

Practical recommendation for CicloUrbana: do not enable it globally. If your startup takes too long, the problem is almost never the beans but something specific —a database connection, an external client that makes a call at startup— and the right fix is a targeted @Lazy on that bean, or moving the heavy work into an ApplicationReadyEvent.

A nuance about @Lazy: if a lazy bean is constructor-injected into a non-lazy one, it will have to be created anyway when the latter is built... unless the injection point also carries @Lazy, in which case Spring injects a proxy and defers the real creation.

  1. BeanPostProcessor: the extension point

A BeanPostProcessor is a special bean that the container gives the chance to inspect or replace every bean right before and right after its initialisation. It is the hook half of Spring rests on:

What looks like magic Which BeanPostProcessor does it
@Autowired and @Value working AutowiredAnnotationBeanPostProcessor
@PostConstruct and @PreDestroy running CommonAnnotationBeanPostProcessor
@Transactional opening transactions InfrastructureAdvisorAutoProxyCreator
@Cacheable, @Async, @Scheduled Their respective post-processors
@Repository translating exceptions PersistenceExceptionTranslationPostProcessor

The interface has two methods, both with a default implementation:

public interface BeanPostProcessor {

    default Object postProcessBeforeInitialization(Object bean, String beanName) {
        return bean;   // before @PostConstruct
    }

    default Object postProcessAfterInitialization(Object bean, String beanName) {
        return bean;   // after @PostConstruct; this is where proxies are returned
    }
}

The key is that both return an object. If you return something other than the bean you received, that will be the object that ends up registered. This is exactly how Spring replaces your StationService with a proxy that opens transactions before each method.

A BeanPostProcessor of our own for CicloUrbana

Let us measure how long each bean in the project takes to initialise, so we can spot slow startups:

package com.ciclourbana.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

/**
 * Times the initialisation of the com.ciclourbana beans and reports
 * those that take longer than the threshold. A startup diagnostic tool.
 */
@Component
public class BeanInitializationTimer implements BeanPostProcessor {

    private static final Logger log =
            LoggerFactory.getLogger(BeanInitializationTimer.class);

    private static final long THRESHOLD_MS = 50;

    // The post-processor runs on the startup thread: a HashMap is enough
    private final Map<String, Long> startTimes = new HashMap<>();

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) {
        if (isOurs(bean)) {
            startTimes.put(beanName, System.nanoTime());
        }
        return bean;   // we return the same bean: we are not replacing it
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        Long start = startTimes.remove(beanName);
        if (start != null) {
            long ms = (System.nanoTime() - start) / 1_000_000;
            if (ms >= THRESHOLD_MS) {
                log.warn("Slow bean: '{}' took {} ms to initialise", beanName, ms);
            } else {
                log.debug("Bean '{}' initialised in {} ms", beanName, ms);
            }
        }
        return bean;
    }

    private boolean isOurs(Object bean) {
        return bean.getClass().getName().startsWith("com.ciclourbana");
    }
}

Turn it on in debug mode to see all the timings:

logging.level.com.ciclourbana.common.BeanInitializationTimer=DEBUG
c.c.c.BeanInitializationTimer : Bean 'inMemoryStationRepository' initialised in 2 ms
c.c.c.BeanInitializationTimer : Bean 'stationService' initialised in 1 ms
c.c.c.BeanInitializationTimer : Bean 'availabilityCache' initialised in 4 ms
c.c.c.BeanInitializationTimer : Bean 'standardFare' initialised in 0 ms

Two important warnings about BeanPostProcessors:

They are created very early, before the ordinary beans. That is why you must not inject dependencies into them that need complex configuration: you could force other beans to be created prematurely and skip their own post-processing (Spring warns in the log with a message along the lines of "is not eligible for getting processed by all BeanPostProcessors").

They run for every bean, Spring's internal ones included. There are hundreds. Always filter by package or by annotation, as our isOurs(...) does, or you will produce a useless log.

Their close relative, the BeanFactoryPostProcessor, acts one step earlier: it modifies the BeanDefinitions (the recipes) before anything is instantiated. It is what Spring uses to resolve the ${...} placeholders in properties. You will rarely need to write one.

Common Mistakes and Tips

Mutable state in a singleton. The most serious mistake in this lesson. A non-final, non-concurrent field in a @Service is a time bomb that only goes off under load. Mechanical rule: in a bean, every field should be final, and if its contents are mutable, it must be a concurrent structure.

Heavy work in the constructor. The constructor should only assign dependencies. Queries, connections and computations belong in @PostConstruct or, better still, in an ApplicationReadyEvent. A constructor that fails leaves the context in a state that is hard to diagnose.

Expecting @PreDestroy on a prototype bean. It never runs. If the bean manages a resource, close it yourself.

Using javax.annotation.PostConstruct. In Spring Boot 3 the package is jakarta.annotation. It is the same javax→jakarta change we saw in lesson 01-01. With the old import the method simply does not run, with no error at all: silent and baffling.

Constructor-injecting a prototype into a singleton. The first instance is frozen in and the scope is lost. Use ObjectProvider.

Using @SessionScope in a REST API. It breaks statelessness and complicates scaling. The user's state goes in the token (module 5).

Enabling spring.main.lazy-initialization=true in production. You are trading startup errors for errors in the user's face. Use it, at most, in development.

Tip: when in doubt about the scope, it is singleton. 95% of the beans in a real application are stateless singletons. When you feel tempted to reach for prototype, ask yourself first whether the state could not travel as a method parameter.

Tip: use @PostConstruct to validate the configuration. Checking at startup that a critical parameter makes sense turns a production error into a startup failure. In lesson 02-05 we will see that Bean Validation does exactly this declaratively.

Tip: to diagnose the lifecycle, raise the log level. logging.level.org.springframework.beans.factory=DEBUG shows each bean being created, in order. It is noisy, but it settles questions in minutes.

Exercises

Exercise 1: demonstrate the scopes

Create two components that do nothing but record their identity: SingletonCounter (default scope) and PrototypeCounter (@Scope("prototype")), both with an id field generated in the constructor. From a CommandLineRunner, ask the context for each of them three times and check on the console that the singleton always has the same id and the prototype a different one each time. Add a @PreDestroy to both and observe which one runs on shutdown.

Exercise 2: fix a stateful singleton

Start from this class, which has a concurrency bug, and correct it without changing its public API. Then demonstrate the problem by firing 100 simultaneous threads at the original method.

@Service
public class ActiveRentalsRegistry {

    private int totalStarted = 0;
    private final List<String> activePlates = new ArrayList<>();

    public void start(String plate) {
        activePlates.add(plate);
        totalStarted++;
    }

    public void finish(String plate) {
        activePlates.remove(plate);
    }

    public int active() {
        return activePlates.size();
    }

    public int totalStarted() {
        return totalStarted;
    }
}

Exercise 3: a BeanPostProcessor that validates a custom annotation

Create a @RequiresInitialization annotation and a BeanPostProcessor that, for every bean in the com.ciclourbana package marked with it, checks in postProcessAfterInitialization that a boolean isInitialized() method returns true. If it is not, the startup must fail with a clear message. Apply it to AvailabilityCache.


Solutions

Solution 1

package com.ciclourbana.common;

import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import java.util.UUID;

@Component
public class SingletonCounter {

    private static final Logger log = LoggerFactory.getLogger(SingletonCounter.class);

    private final String id = UUID.randomUUID().toString().substring(0, 8);

    public SingletonCounter() {
        log.info("Constructed SingletonCounter {}", id);
    }

    public String id() {
        return id;
    }

    @PreDestroy
    public void onDestroy() {
        log.info("Destroyed SingletonCounter {}", id);
    }
}
package com.ciclourbana.common;

import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

import java.util.UUID;

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

    private static final Logger log = LoggerFactory.getLogger(PrototypeCounter.class);

    private final String id = UUID.randomUUID().toString().substring(0, 8);

    public String id() {
        return id;
    }

    @PreDestroy
    public void onDestroy() {
        // NEVER runs: Spring does not manage the destruction of prototypes
        log.info("Destroyed PrototypeCounter {}", id);
    }
}
package com.ciclourbana.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
@Order(50)
public class ScopeDemo implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(ScopeDemo.class);

    private final ApplicationContext context;

    public ScopeDemo(ApplicationContext context) {
        this.context = context;
    }

    @Override
    public void run(String... args) {
        for (int i = 1; i <= 3; i++) {
            log.info("Request {} -> singleton: {} | prototype: {}",
                    i,
                    context.getBean(SingletonCounter.class).id(),
                    context.getBean(PrototypeCounter.class).id());
        }
    }
}

Output:

Request 1 -> singleton: a3f91c02 | prototype: 7b2e4d81
Request 2 -> singleton: a3f91c02 | prototype: c14a9f30
Request 3 -> singleton: a3f91c02 | prototype: 55d0e7b2
...
Destroyed SingletonCounter a3f91c02       <- only the singleton's appears

Comment: the prototype's @PreDestroy shows up for none of the three instances, confirming what was said in section 3. Note as well that here we do use context.getBean(...): in a teaching demonstration that is legitimate, but remember that in business logic it is the service locator antipattern.

Solution 2

Diagnosis: there are two race conditions. activePlates is an ArrayList (not thread-safe: two simultaneous add calls can lose an element or corrupt the internal array) and totalStarted++ is not atomic (it is read, add and write; two threads can read the same value and lose an increment).

package com.ciclourbana.rentals;

import org.springframework.stereotype.Service;

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Registry of rentals in progress. It is a singleton shared by every
 * Tomcat thread, so all of its state must be concurrent.
 */
@Service
public class ActiveRentalsRegistry {

    // A concurrent Set: it also rules out duplicates, which in this domain
    // are an error (a bike cannot be rented out twice)
    private final Set<String> activePlates = ConcurrentHashMap.newKeySet();

    // Atomic increment: no lost updates
    private final AtomicInteger totalStarted = new AtomicInteger();

    public void start(String plate) {
        if (activePlates.add(plate)) {   // add returns false if it was already there
            totalStarted.incrementAndGet();
        }
    }

    public void finish(String plate) {
        activePlates.remove(plate);
    }

    public int active() {
        return activePlates.size();
    }

    public int totalStarted() {
        return totalStarted.get();
    }
}

And the demonstration of the problem with the original version:

package com.ciclourbana.rentals;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@Component
public class ConcurrencyProbe implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(ConcurrencyProbe.class);

    private final ActiveRentalsRegistry registry;

    public ConcurrencyProbe(ActiveRentalsRegistry registry) {
        this.registry = registry;
    }

    @Override
    public void run(String... args) throws InterruptedException {
        int threads = 100;
        CountDownLatch startSignal = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(threads);

        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < threads; i++) {
                String plate = String.format("RB-%04d", i);
                pool.submit(() -> {
                    try {
                        startSignal.await();            // they all start at once
                        registry.start(plate);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    } finally {
                        done.countDown();
                    }
                });
            }
            startSignal.countDown();
            done.await();
        }

        log.info("Expected 100 | active: {} | totalStarted: {}",
                registry.active(), registry.totalStarted());
    }
}

With the original version you will see output like active: 97 | totalStarted: 94, and it will vary on every run. With the corrected one, always 100 | 100.

Comment and a tip: Executors.newVirtualThreadPerTaskExecutor() comes from Java 21 and makes these tests trivial to write. The starting CountDownLatch matters: without it the threads start in a staggered fashion and the race may not show itself, giving the false impression that the original code is correct. Concurrency bugs are like that: failing to reproduce one does not mean it is not there.

Solution 3

package com.ciclourbana.common;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Marks a bean that must be operational once its initialisation finishes.
 * The annotated class must expose a public boolean isInitialized() method.
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresInitialization {

    /** Description used in the error message. */
    String value() default "";
}
package com.ciclourbana.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.BeanInitializationException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

@Component
public class InitializationVerifier implements BeanPostProcessor {

    private static final Logger log = LoggerFactory.getLogger(InitializationVerifier.class);

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        // AnnotationUtils sees through proxies and class hierarchies
        RequiresInitialization marker =
                AnnotationUtils.findAnnotation(bean.getClass(), RequiresInitialization.class);

        if (marker == null) {
            return bean;
        }

        try {
            Method method = bean.getClass().getMethod("isInitialized");
            boolean ready = (boolean) method.invoke(bean);

            if (!ready) {
                throw new BeanInitializationException(
                        "Bean '" + beanName + "' did not finish initialised. "
                                + marker.value());
            }
            log.info("Bean '{}' verified: initialisation correct", beanName);

        } catch (NoSuchMethodException e) {
            throw new BeanInitializationException(
                    "Bean '" + beanName + "' carries @RequiresInitialization "
                            + "but exposes no public boolean isInitialized() method", e);
        } catch (ReflectiveOperationException e) {
            throw new BeanInitializationException(
                    "Could not verify the initialisation of '" + beanName + "'", e);
        }

        return bean;   // we do not replace the bean, we only validate it
    }
}

And applying it to the cache:

@Component
@RequiresInitialization("The availability cache must be preloaded before accepting traffic.")
public class AvailabilityCache {

    // ... the rest exactly as in section 7 ...

    /** Contract required by @RequiresInitialization. */
    public boolean isInitialized() {
        return loadedAt != null;
    }
}

Output on a correct startup:

c.c.common.InitializationVerifier : Bean 'availabilityCache' verified: initialisation correct

And if you comment out the this.loadedAt = Instant.now() line in the @PostConstruct:

***************************
APPLICATION FAILED TO START
***************************

org.springframework.beans.factory.BeanInitializationException: Bean
'availabilityCache' did not finish initialised. The availability cache
must be preloaded before accepting traffic.

Comment: pay attention to the order. The BeanPostProcessor acts in postProcessAfterInitialization, that is, after the @PostConstruct, which is precisely where it makes sense to check that the initialisation worked. Had we put it in postProcessBeforeInitialization, loadedAt would always be null and every bean would fail.

This exercise is, in miniature, exactly the mechanism by which @Transactional or @Cacheable work: an annotation that on its own does nothing, plus a post-processor that detects it and acts. The only difference is that those, instead of returning the same bean, return a proxy that wraps it.

Conclusion

The container has stopped being a black box. You know that a bean is an object whose lifecycle Spring manages, and that this management is the only thing separating StationService from an object created with new —and also the reason why behavioural annotations only work on beans. You know the difference between BeanFactory and ApplicationContext, and why the latter creates singletons eagerly: to fail at startup rather than in front of the user. You have mastered the five scopes, you know that Spring does not destroy prototypes and you know how to solve the classic problem of injecting a prototype into a singleton with ObjectProvider. Above all, you have internalised the rule that prevents the most grief: a singleton cannot hold mutable state, which is why InMemoryStationRepository uses ConcurrentHashMap and AtomicLong. You know the eleven steps of the lifecycle, you have hooked code onto a bean's birth and death with @PostConstruct and @PreDestroy, you have discovered the temporal dependency between that preload and the CommandLineRunners, and you have written your first BeanPostProcessor, the very mechanism that transactions and declarative caching rest on.

CicloUrbana now adds AvailabilityCache, with a preload at startup and a statistics dump on shutdown, and BeanInitializationTimer as a startup diagnostic tool.

There is something we have been dodging lesson after lesson: the hard-coded numbers. The 0.50 unlock price, the 0.12 price per minute, the 15 free minutes of the student fare, the timer's 50 ms threshold, the minimum capacity of 8 docks. All of those are constants compiled into the code, and changing any of them today forces a recompile and a redeployment. The next lesson, Spring Boot Configuration, tackles that problem at the root: the Environment and its property sources, the exact order of precedence between command-line arguments, environment variables and files, the differences between .properties and .yaml, relaxed binding, @Value with SpEL and —very importantly— why credentials must never live in the repository.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved