Spring Study Guide – Core Spring

August 4, 202611 min readUpdated 8/18/2026

Everything else in Spring sits on top of this. The container creates your objects, wires them together, and wraps some of them in proxies — and almost every surprising thing Spring does is explained by one of those three facts.

Dependency injection

What is dependency injection?

An object does not create or look up what it depends on. Something else supplies them. In Spring that something else is the container.

What does it actually buy you?

  • You can substitute the dependency. A test passes a mock; production passes the real thing. Without DI the collaborator is hard-coded inside a constructor and there is no seam.
  • The class stops knowing how to build its world. It declares what it needs and nothing about where it comes from.
  • Wiring lives in one place instead of being smeared across every new.

Is dependency injection a design pattern?

It is a specific form of inversion of control. IoC is the principle — control over flow and construction is handed to a framework; DI is the mechanism.

Why inject through interfaces?

Because the caller then depends on a contract rather than an implementation, so the implementation can be swapped, decorated or proxied without the caller changing. That last one is not theoretical: it is exactly how @Transactional works.

The three ways to inject, and which to use

// Constructor injection - the one to use.
// @RequiredArgsConstructor generates the constructor over the final fields, and since
// Spring 4.3 a single constructor needs no @Autowired at all.
@Service
@RequiredArgsConstructor
public class OrderPlacedListener {

    private final CustomerOrderService orderService;
    private final MailService mailService;
    private final OrderMessagePublisher messagePublisher;
}
// Field injection - concise, and worse.
@Service
public class ProductServiceImpl implements ProductService {

    @Autowired
    private ProductDAO productDAO;
}

Why is constructor injection preferred?

  • The fields can be final, so the object is immutable and fully formed the moment it exists.
  • A missing dependency fails at startup, not on the first call.
  • The class can be built with new in a plain unit test, with no Spring at all.
  • A constructor with eight arguments is visibly wrong. Eight @Autowired fields look fine, which is how classes quietly grow into god objects.

Setter injection is the third option, and is only worth reaching for when a dependency is genuinely optional or genuinely needs to change after construction. That is rare.

The ApplicationContext

What is the application context?

Spring's container. It reads your configuration, instantiates the beans, injects their dependencies, applies post-processing, publishes lifecycle events, and holds the lot until it closes. ApplicationContext extends BeanFactory and adds the things real applications need — event publishing, message resolution, resource loading, automatic BeanPostProcessor registration.

How do you create one?

// In a Spring Boot application you never write this - SpringApplication.run does it,
// and returns the context it created.
ApplicationContext context =
        SpringApplication.run(PizzaSpringbootBackendApplication.class, args);

// Plain Spring, for reference:
var ctx = new AnnotationConfigApplicationContext(AppConfig.class);
var ctx2 = new ClassPathXmlApplicationContext("beans.xml");   // legacy

What is the container's lifecycle?

  1. Bean definitions are loaded — from component scanning, @Bean methods, XML.
  2. BeanFactoryPostProcessors run and may edit those definitions.
  3. Beans are instantiated, and their dependencies injected.
  4. BeanPostProcessors run around each bean's initialisation.
  5. The context is ready; ContextRefreshedEvent is published.
  6. On shutdown, destruction callbacks run in reverse order.

How do you close it properly? Does Spring Boot do it for you?

Yes. SpringApplication.run registers a JVM shutdown hook, so a normal exit closes the context and runs every destroy callback. Written by hand, use try-with-resources — ConfigurableApplicationContext is AutoCloseable.

The bean lifecycle

Describe the lifecycle of a bean.

  1. Instantiate — the constructor runs, and constructor injection happens here.
  2. Populate — fields and setters are injected.
  3. Aware callbacks — BeanNameAware, ApplicationContextAware and friends.
  4. BeanPostProcessor.postProcessBeforeInitialization.
  5. @PostConstruct.
  6. InitializingBean.afterPropertiesSet(), then any custom initMethod.
  7. BeanPostProcessor.postProcessAfterInitializationthis is where proxies are created.
  8. Bean is in use.
  9. @PreDestroy, then DisposableBean.destroy(), then any custom destroyMethod.

Step 7 is the one worth remembering. The object your @PostConstruct ran on is not necessarily the object other beans get injected with — they get the proxy that wrapped it afterwards.

@Service
public class JwtService {

    private SecretKey key;

    // Runs after injection, so properties are available. Failing here fails the
    // application at startup rather than on the first login - which is the point.
    @PostConstruct
    void init() {
        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);
    }
}

What are the other ways to declare init and destroy methods?

Three, in order of preference: the @PostConstruct / @PreDestroy annotations (portable, no Spring coupling); initMethod / destroyMethod attributes on @Bean (the only option for a third-party class you cannot annotate); and implementing InitializingBean / DisposableBean (couples your class to Spring — avoid).

Note that destroy callbacks never run for a prototype bean. The container hands it over and forgets it. Anything holding a resource must not be a prototype, or must be closed by whoever asked for it.

Scopes

What scopes exist and what is the default?

ScopeOne instance per
singleton (default)container
prototypeinjection or getBean call
requestHTTP request
sessionHTTP session
applicationServletContext
websocketWebSocket session

The last four only exist in a web-aware context.

Singleton means one per container, not one per JVM. Two contexts in the same process each get their own. And a singleton is shared across every request thread, so a singleton with mutable state is a data race — which is why the services in the pizza API hold only their injected collaborators and nothing else.

What happens if you inject a prototype into a singleton?

You get exactly one instance, injected once at startup, and it never changes. That is nearly always a bug. If you need a fresh instance per call, inject an ObjectProvider<T> and call getObject(), or use a scoped proxy.

Defining beans

@Component versus @Bean — when do you use which?

@Component@Bean
Goes onyour own classa method in a @Configuration class
Found bycomponent scanningbeing declared
Use it forcode you ownthird-party classes, or anything needing construction logic
@Configuration
@EnableCaching
public class CacheConfig {

    public static final String MENU_CACHE = "menu";

    // ConcurrentMapCacheManager is not our class, so it cannot be annotated.
    // A @Bean method is the only way to contribute it - and it lets us configure it.
    @Bean
    public ConcurrentMapCacheManager cacheManager() {
        var manager = new ConcurrentMapCacheManager(MENU_CACHE, MENU_BY_TYPE_CACHE);
        manager.setAllowNullValues(false);
        return manager;
    }
}

What is the default bean id, and how do you change it?

For @Bean, the method name. For @Component, the class name with a lower-case first letter. Override with @Bean(name = "...") or @Component("...").

What does component scanning do, and where does it look?

It walks the classpath under a set of base packages looking for classes annotated with @Component or a meta-annotation of it — @Service, @Repository, @Controller, @RestController, @Configuration — and registers a bean definition for each. @SpringBootApplication implies @ComponentScan with the annotated class's own package as the base, which is why that class belongs at the root of your package tree.

The stereotype annotations are not merely decorative. @Repository in particular enables translation of vendor-specific persistence exceptions into Spring's DataAccessException hierarchy.

Why would you make a @Bean method static?

Because BeanFactoryPostProcessors must be created very early — before the enclosing configuration class can be fully initialised. A static method can be called without an instance, which avoids forcing the configuration class into premature instantiation.

Resolving ambiguity

What happens when two beans match one injection point?

NoUniqueBeanDefinitionException, at startup. Resolve it one of three ways:

  1. @Primary on the one that should win by default.
  2. @Qualifier("name") at the injection point to name the one you want.
  3. Name the field or parameter after the bean — matching by name is the fallback after matching by type.
// Two ThreadPoolTaskExecutor-ish beans exist, so the injection point names one.
@Autowired
@Qualifier(value = "taskExecutor")
private ThreadPoolTaskExecutor taskExecutor;

What if a bean might not exist at all?

ObjectProvider<T>. It resolves lazily and tolerates absence, which is how one security configuration can cover both the profile where OAuth2 is configured and the profile where it is not:

private final ObjectProvider<ClientRegistrationRepository> clientRegistrationRepository;

// ...
if (clientRegistrationRepository.getIfAvailable() != null) {
    http.oauth2Login(oauth2 -> oauth2.successHandler(successHandler));
}

Lazy or eager?

Are beans created lazily or eagerly?

Singletons are created eagerly, when the context starts. Prototypes are created on demand. Add @Lazy to defer a singleton until first use.

Eager is the right default: a misconfigured bean fails the application at startup rather than at 3am on the first request that happens to need it. Reach for @Lazy to break a circular dependency or to skip building something genuinely expensive and rarely used — and treat the first of those as a design smell rather than a solution.

Configuration values

How do you inject a scalar value?

@Value("${pizza.oauth2.success-redirect:http://localhost:5173/oauth2/callback}")
private String successRedirect;

The part after : is the default, used when the property is absent. Without a default, a missing property fails the context — which is usually what you want.

What is the Environment abstraction?

The container's unified view of properties and active profiles, whatever their source — command line, environment variables, application.properties, defaults. Ask it directly when you need to:

Environment env = ctx.getEnvironment();
System.out.println("Active Profile: " + Arrays.toString(env.getActiveProfiles()));
System.out.println("Port: " + env.getProperty("server.port"));

What is a property source, and what does @PropertySource do?

A property source is one named set of key/value pairs. The Environment holds an ordered list of them and returns the first hit, which is exactly how a command-line argument overrides a file. @PropertySource adds a file to that list. PropertySourcesPlaceholderConfigurer is the BeanFactoryPostProcessor that resolves ${...} placeholders against it — Boot registers one for you.

What is SpEL, and what is the difference between $ and #?

Spring Expression Language — an expression language for querying and manipulating an object graph at runtime. The two prefixes are completely different things:

  • ${...} is a property placeholder. Resolved from the Environment, before SpEL is involved at all.
  • #{...} is a SpEL expression. Evaluated as code — it can reference beans, call methods, do arithmetic.
@Value("${server.port}")            // the property named server.port
@Value("#{systemProperties['user.region']}")   // a SpEL expression
@Cacheable(value = "menuByType", key = "#type")   // SpEL over the method's arguments
@PreAuthorize("hasRole('ADMIN')")                 // SpEL, over the security context

The last two are why SpEL is worth knowing even if you never write #{} yourself: the caching and security annotations are SpEL all the way down.

Prefer @ConfigurationProperties to @Value

This is the modern answer, and it is not a style preference. The pizza API replaced nine scattered @Value annotations with one record — and two of the nine were the same key declared in two different classes, with nothing checking they still agreed.

@Validated
@ConfigurationProperties(prefix = "pizza")
public record PizzaProperties(
        @Valid Pricing pricing,
        @Valid Jwt jwt,
        @Valid Cors cors) {

    public record Pricing(
            @DecimalMin("0.0") @DecimalMax("1.0") BigDecimal taxRate,
            @DecimalMin("0.0") BigDecimal deliveryFee) {}

    public record Jwt(@NotBlank @Size(min = 32) String secret, @Positive long expirationMinutes) {}

    // Binding to List<String> removes the split(",") every @Value version does by hand.
    public record Cors(@NotEmpty List<String> allowedOrigins) {}
}

Three things you get that @Value cannot give you: types (a BigDecimal is a BigDecimal, a list is a list), immutability (a record component is set once by the constructor), and validation at startup@Validated checks the constraints while the context is building, so a missing pizza.jwt.secret fails at boot with a message naming the property instead of becoming a null field and a NullPointerException on the first login.

Profiles

What are profiles for and how do you configure them?

A profile is a named group of beans and properties that is only active when you say so. Use it when whole components differ per environment — not for a value that differs, which is what properties are for.

@Service
@Profile("search")   // no Elasticsearch bean exists unless this profile is on,
public class ProductSearchServiceImpl implements ProductSearchService { ... }
spring.profiles.active=local
# and application-local.properties is layered over application.properties

Activate with spring.profiles.active, the SPRING_PROFILES_ACTIVE environment variable, or --spring.profiles.active= on the command line. You can have as many active at once as you like. @Profile("!prod") negates; @Profile works on @Component and @Bean alike.

Proxies

What is a proxy, and which two types can Spring create?

A proxy is a stand-in object with the same type as the real bean. Callers hold the proxy; it adds behaviour and then delegates. This is how @Transactional, @Cacheable, @Async, @PreAuthorize, @Retryable and every AOP aspect are implemented.

JDK dynamic proxyCGLIB
Requiresthe bean implements an interfacenothing — subclasses the class
Proxiesonly interface methodspublic and protected methods
Cannot handlea class with no interfacefinal classes or final methods

Spring Boot defaults to CGLIB (proxyTargetClass=true) so it works either way.

What visibility must a method have to be advised?

public — with CGLIB, protected also works. private, static and final methods are never advised, and nothing warns you.

⚠️ The rule that catches everyone: self-invocation.

public void a() { b(); }   // b() is NOT advised - this call never leaves the object
public void b() { ... }    // advised only when ANOTHER bean calls it

Advice runs when a call arrives through the proxy. A call to a sibling method on this goes straight down the vtable and the proxy never sees it. So a @Transactional method called from another method of the same class runs with no transaction at all — silently. If an annotation "isn't working", check for a self-invocation before anything else.

Post-processors

BeanFactoryPostProcessor versus BeanPostProcessor?

BeanFactoryPostProcessorBeanPostProcessor
Operates onbean definitionsbean instances
Runsonce, before any bean is createdaround every bean's initialisation
ExamplePropertySourcesPlaceholderConfigurerthe one that wraps beans in AOP proxies

What to remember

  • Constructor injection, final fields, no @Autowired needed.
  • Singletons are eager and shared — so they must be stateless.
  • Proxies are created in postProcessAfterInitialization, which is why self-invocation bypasses every annotation built on them.
  • ${} is a property; #{} is SpEL. Different mechanisms entirely.
  • @ConfigurationProperties over @Value: typed, immutable, validated at startup.