Spring Boot – Beans, Scopes and Lifecycle

June 19, 20264 min readUpdated 8/18/2026

A bean is an object the Spring container creates, configures and hands out. That is the whole definition. Everything else — scopes, lifecycle callbacks, @Primary — is detail about which object and when.

Two ways to declare one

Stereotype annotations, for classes you own. Spring finds them by component scanning and instantiates them:

@Service
public class ProductServiceImpl implements ProductService { /* … */ }

@Repository
public class ProductDAOImp implements ProductDAO { /* … */ }

@RestController
public class ProductRestController { /* … */ }

@Component
public class ServiceTimingAspect { /* … */ }

All four are @Component underneath. The difference is intent, plus one behaviour: @Repository also translates vendor-specific persistence exceptions into Spring's DataAccessException hierarchy, so a MySQL error and a Postgres error arrive as the same type.

@Bean methods, for objects you do not own — third-party classes you cannot annotate:

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI pizzaOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("Pizza API")
                        .version("v1")
                        .description("Ordering API for the pizza demo app."))
                .components(new Components()
                        .addSecuritySchemes("bearerAuth",
                                new SecurityScheme()
                                        .type(SecurityScheme.Type.HTTP)
                                        .scheme("bearer")
                                        .bearerFormat("JWT")));
    }
}

OpenAPI comes from springdoc. You cannot put @Component on it, so you build it in a method and let Spring manage the result. The method name becomes the bean name — here, pizzaOpenAPI.

The rule of thumb: your class, use a stereotype; someone else's class, use @Bean.

Scopes

Singleton is the default, and it is the right answer nearly always. One instance per container, created at startup, shared by everyone.

This has a consequence people miss: a singleton bean must be stateless, or it must make its state thread-safe itself. One instance is serving every concurrent request.

@Service
public class BrokenService {

    // A field on a singleton is shared by every request on every thread.
    // Two customers checking out at once will overwrite each other's total.
    private BigDecimal currentTotal;

    public void process(Order order) {
        this.currentTotal = order.getTotal();   // race
        // …
    }
}

Keep per-request state in local variables and method parameters, which live on the calling thread's stack. Notice that every service in the pizza API does exactly this — the fields are collaborators (DAOs, mappers), never data.

The other scopes, briefly:

ScopeOne instance perUse when
singletoncontaineralmost always
prototypeinjection pointthe object carries mutable per-use state
requestHTTP requestper-request context in a web app
sessionHTTP sessionrare; stateless APIs have no session
@Component
@Scope("prototype")
public class ReportBuilder { /* new instance every time it is injected or requested */ }

⚠️ A prototype injected into a singleton is injected once. The singleton is created once, so its dependency is resolved once, and you get one "prototype" instance for the life of the application. If you genuinely need a fresh one per call, inject an ObjectProvider<ReportBuilder> and call getObject().

Lifecycle

Two callbacks matter, and they run around dependency injection:

@Service
public class JwtService {

    private final PizzaProperties properties;
    private SecretKey key;

    public JwtService(PizzaProperties properties) {
        this.properties = properties;   // 1. constructor - dependencies arrive
    }

    @PostConstruct
    void init() {                        // 2. after injection, before first use
        byte[] keyBytes = properties.jwt().secret().getBytes(StandardCharsets.UTF_8);
        if (keyBytes.length < 32) {
            throw new IllegalStateException(
                    "pizza.jwt.secret must be at least 32 characters for HS256");
        }
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    @PreDestroy
    void shutdown() {                    // 3. on graceful shutdown
        // release anything that needs releasing
    }
}

@PostConstruct exists because the constructor is too early for anything that uses an injected dependency in the field-injection style, and because derived state should be computed once rather than on every call. The example above is a good one: the key is derived from configuration, validated, and cached.

Note what the validation buys. Without it, a short secret fails on the first login attempt, inside the JWT library, with a message about key length that mentions nothing you configured. With it, the application refuses to start and says which property is wrong. Fail at startup, not at first use — that is most of what @PostConstruct is for.

When two beans satisfy one type

The moment there are two implementations of an interface, injection becomes ambiguous:

Parameter 0 of constructor in OrderServiceImpl required a single bean,
but 2 were found:
    - stripePaymentGateway
    - paypalPaymentGateway

@Primary names the default:

@Primary
@Service
public class StripePaymentGateway implements PaymentGateway { }

@Service
public class PayPalPaymentGateway implements PaymentGateway { }

@Qualifier asks for a specific one, and wins over @Primary:

public OrderService(@Qualifier("payPalPaymentGateway") PaymentGateway gateway) {
    this.gateway = gateway;
}

The pizza API needs this for its thread pools, because it declares two beans of closely related types and one of them must be picked explicitly:

@Autowired
@Qualifier(value = "taskExecutor")
private ThreadPoolTaskExecutor taskExecutor;

Use @Primary when one choice is obviously the normal one and the other is a special case. Use @Qualifier at the injection point when the caller genuinely has to choose. If you find yourself writing @Qualifier everywhere, the types are probably wrong — two things that are never interchangeable should not share an interface.

Bean names

Every bean has a name, defaulting to the decapitalised class name (ProductServiceImplproductServiceImpl) or, for @Bean methods, the method name. You can set it explicitly:

@Bean(name = "taskExecutor")
public ThreadPoolTaskExecutor taskExecutor() { /* … */ }

That one is not cosmetic. Spring's @Async support looks for a bean literally named taskExecutor; name it something else and your async work silently runs on a default executor instead of the pool you carefully sized. Lesson 28 covers this.

What to take from this

  • Your class → stereotype. Someone else's class → @Bean method.
  • Singletons must be stateless. Fields are collaborators, not data.
  • @PostConstruct is where you validate configuration so failures happen at startup with a useful message.
  • @Primary for the usual choice, @Qualifier when the caller must decide.

Next: dependency injection — why constructor injection is the only default worth having.