Week 2: Spring Core & Dependency Injection

Last week you put @SpringBootApplication on a class, ran it, and a working web server appeared — without ever writing new for a single one of your own components. This week pulls that curtain back. You'll learn what the IoC container actually is, how it decides what beans to create and how to wire them together, why constructor injection is the recommendation you'll see in every well-run Spring codebase, and what "auto-configuration" and "starters" are really doing behind the scenes.

Module 2 of 12 Week 2 of 15 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Understand the IoC container and the bean lifecycle it manages
  • Use constructor injection and component scanning correctly by default
  • Explain what auto-configuration and starters actually do under the hood

1. The IoC Container & Beans

In ordinary Java, an object that needs a collaborator usually creates it directly — new OrderRepository() inside OrderService, for example. That means OrderService owns the decision of exactly which implementation it depends on and exactly when it's constructed. Inversion of Control (IoC) flips that: instead of your class constructing its own dependencies, something external constructs them and hands them to your class. That "something external" is the IoC container, and in Spring it's represented at runtime by an ApplicationContext.

A bean is simply an object whose creation and lifecycle the container manages, instead of you managing it with new. When SpringApplication.run() executes, Spring builds an ApplicationContext, figures out which classes should become beans, works out the order they need to be created in based on what depends on what, and wires the whole graph together — all before your first log line prints.

GreetingService.java & GreetingController.java
package com.codeverse.week02;

import org.springframework.stereotype.Service;

@Service
public class GreetingService {

    public String greet(String name) {
        return "Hello, " + name + "!";
    }
}

package com.codeverse.week02;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GreetingController {

    private final GreetingService greetingService;

    public GreetingController(GreetingService greetingService) {
        this.greetingService = greetingService;
    }

    @GetMapping("/greet")
    String greet(@RequestParam(defaultValue = "world") String name) {
        return greetingService.greet(name);
    }
}

Nothing in this code ever calls new GreetingService() or new GreetingController(...). At startup, Spring sees @Service on GreetingService and registers it as a bean. It sees @RestController on GreetingController, notices its constructor asks for a GreetingService, finds the GreetingService bean it already created, and passes it in. That resolution — "this bean needs that bean, so build that one first" — is the container doing dependency injection for you.

2. Bean Lifecycle & Scopes

A bean's life has more stages than "constructed, then used." The container instantiates it, injects its dependencies, runs any initialization callbacks, hands it out whenever something needs it, and — for beans it owns until the end — runs cleanup callbacks when the application context shuts down.

ConnectionPoolManager.java
package com.codeverse.week02;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Component;

@Component
public class ConnectionPoolManager {

    @PostConstruct
    void warmUp() {
        // Runs once, right after Spring has injected every dependency
        // this bean needed -- a safe place to open connections, prime
        // caches, or validate configuration.
        System.out.println("ConnectionPoolManager: pool warmed up");
    }

    @PreDestroy
    void shutdown() {
        // Runs once, as the ApplicationContext closes -- release
        // resources cleanly instead of leaking them.
        System.out.println("ConnectionPoolManager: pool drained");
    }
}

@PostConstruct fires after the constructor and all dependency injection are complete, so it's the right place for setup logic that depends on those injected collaborators being present. @PreDestroy fires as the context shuts down, useful for closing connections or flushing buffers.

Singleton vs. prototype scope

By default every bean is a singleton — the container creates exactly one instance and hands out that same instance everywhere it's needed. This is almost always what you want for services, repositories, and controllers, which hold no per-request mutable state of their own.

A prototype-scoped bean gets a brand-new instance every time it's requested from the container:

ReportBuilder.java
package com.codeverse.week02;

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

@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ReportBuilder {
    // Accumulates mutable state across several method calls while one
    // report is being assembled -- sharing a singleton instance of this
    // across concurrent requests would corrupt that state.
    private final StringBuilder buffer = new StringBuilder();

    public ReportBuilder append(String line) {
        buffer.append(line).append(System.lineSeparator());
        return this;
    }

    public String build() {
        return buffer.toString();
    }
}

You'd reach for prototype scope specifically when a bean accumulates mutable, per-use state that must not be shared across callers — a stateful builder like the one above, for instance. It's the exception, not the default: reach for it only when you notice a bean actually needs fresh state per use, not preemptively.

3. Dependency Injection Styles

Spring supports three ways to get a dependency into a bean. All three work. Only one of them should be your default.

Field injection — avoid

field injection (avoid)
@Service
public class OrderService {

    @Autowired
    private PaymentClient paymentClient;   // injected by reflection, after construction
}

Setter injection — for optional dependencies only

setter injection
@Service
public class OrderService {

    private NotificationClient notificationClient;

    @Autowired(required = false)
    public void setNotificationClient(NotificationClient notificationClient) {
        this.notificationClient = notificationClient;
    }
}

Constructor injection — the default

constructor injection (default choice)
@Service
public class OrderService {

    private final PaymentClient paymentClient;
    private final InventoryClient inventoryClient;

    // With a single constructor, @Autowired is optional -- Spring uses
    // it automatically. Keeping it explicit is still common practice.
    public OrderService(PaymentClient paymentClient, InventoryClient inventoryClient) {
        this.paymentClient = paymentClient;
        this.inventoryClient = inventoryClient;
    }
}

Constructor injection wins on every axis that matters in practice:

  • Immutability. Fields can be final, so once OrderService is built, its dependencies can never silently change out from under it.
  • Testability without reflection. A unit test just calls new OrderService(fakePaymentClient, fakeInventoryClient) — no Spring context, no reflection tricks, no test-only annotations required to populate a private field.
  • Fail fast at startup. If a required dependency's bean doesn't exist, the app refuses to start with a clear error, instead of deploying successfully and throwing a NullPointerException the first time a request happens to hit the missing field.
  • Honesty about required vs. optional. A long constructor parameter list is a visible signal that a class is doing too much — field injection hides that signal because dependencies are scattered across the class body instead of declared in one place.
This pays off directly in Week 8

Week 8's testing module leans on exactly the "no reflection required" point above. Every service you write with constructor injection this week can be unit-tested with a plain new call and hand-built fakes — no @MockBean, no Spring context startup, no slow tests.

4. Component Scanning & Stereotypes

Spring doesn't scan your entire classpath looking for beans — it needs to be told which classes are candidates. The stereotype annotations are how you mark a class as one:

  • @Component — the generic marker; "this class is a bean."
  • @Service — a @Component specialization for business logic; purely a naming convention that also documents intent.
  • @Repository — a @Component specialization for data access; additionally enables automatic translation of database exceptions into Spring's unified DataAccessException hierarchy.
  • @Controller / @RestController — a @Component specialization for handling HTTP requests, which you already used in Week 1.

@SpringBootApplication is itself a combination of three annotations, one of which is @ComponentScan. With no arguments, @ComponentScan scans the package containing the annotated class and every sub-package beneath it:

package layout that scanning relies on
com.codeverse.week02
├── Week02Application.java        ← @SpringBootApplication lives here
├── GreetingController.java       ← found: same package
├── service/
│   ├── GreetingService.java      ← found: sub-package
│   └── impl/
│       └── FriendlyGreetingService.java   ← found: sub-sub-package
└── other/
    └── unrelated/
        └── SomeClass.java        ← NOT found if this were outside
                                     com.codeverse.week02 entirely

This is why every generated Spring Boot project puts its @SpringBootApplication class at the root package — one level above everything else. Put it in a leaf package by mistake, and any @Service or @Repository class outside that package's subtree simply never gets registered, with no error — the bean just silently doesn't exist, and you find out when injection fails.

5. @Configuration Classes & @Bean Methods

Stereotype annotations only work on classes you own and can edit. For a class from a third-party library — or anything that needs to be built with logic rather than a bare constructor call — you register it explicitly with a @Configuration class and a @Bean-annotated method instead.

AppConfig.java
package com.codeverse.week02;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AppConfig {

    // ObjectMapper is a third-party class -- you can't add @Component
    // to it. This is the standard way to register a bean for something
    // you don't own, built with whatever setup logic it needs.
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        return mapper;
    }
}

The rule of thumb: reach for a stereotype annotation on your own classes by default; reach for an explicit @Bean method when you're wiring up a class you don't control, when construction needs conditional logic, or when you need more than one bean of the same type configured differently (the second implementation you'll add in this week's exercise is a good example of the latter).

6. Auto-Configuration & Starters Explained

In Week 1, adding a single dependency — spring-boot-starter-web — to pom.xml was enough for an embedded Tomcat server to appear and start handling HTTP requests, with zero configuration written by you. That's auto-configuration, the third piece bundled inside @SpringBootApplication alongside component scanning.

A starter like spring-boot-starter-web is not magic — it's just a Maven dependency that pulls in a curated set of libraries (Tomcat, Spring MVC, Jackson for JSON) plus an auto-configuration module for each. Each auto-configuration class is guarded by conditional annotations that decide whether it should activate at all:

conceptual shape of Spring's own TomcatAutoConfiguration
@Configuration
@ConditionalOnClass(Tomcat.class)
public class TomcatWebServerAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(ServletWebServerFactory.class)
    public ServletWebServerFactory servletWebServerFactory() {
        return new TomcatServletWebServerFactory();
    }
}

@ConditionalOnClass(Tomcat.class) means "only register this configuration if the Tomcat class is actually present on the classpath" — which it is, because spring-boot-starter-web pulled it in as a transitive dependency. Remove that starter, and this whole auto-configuration class quietly never activates; no Tomcat classes, no Tomcat bean. @ConditionalOnMissingBean(ServletWebServerFactory.class) means "only register Spring's default Tomcat factory if you haven't already defined your own bean of that type" — so any @Bean method you write yourself always takes priority over Spring Boot's defaults, letting you override selectively instead of all-or-nothing.

You can see exactly which auto-configuration classes activated — and, just as usefully, which ones Spring considered and rejected, and why — by running your app with the --debug flag:

terminal
./mvnw spring-boot:run -Dspring-boot.run.arguments=--debug

# Look for the "CONDITIONS EVALUATION REPORT" near the top of the log:
#
# Positive matches:
# -----------------
#    DispatcherServletAutoConfiguration matched:
#       - @ConditionalOnClass found required class 'jakarta.servlet.Servlet' ...
#
# Negative matches:
# -----------------
#    DataSourceAutoConfiguration:
#       Did not match:
#          - @ConditionalOnClass did not find required class
#            'javax.sql.DataSource' ...
Tying back to Week 1's pom.xml

That single spring-boot-starter-web dependency you added in Week 1 is the entire reason Tomcat, Spring MVC and Jackson auto-configured themselves. When Week 3 has you add spring-boot-starter-validation or a database starter, the same mechanism — starter on the classpath, matching @ConditionalOnClass auto-configuration activates — is what wires the new capability in.

Looking ahead to Week 3

Understanding that a controller is "just a bean, injected like any other" is what makes Week 3's REST controllers feel unsurprising rather than magical — the routing annotations change, but the constructor-injection pattern you practice this week doesn't.

7. Hands-on Exercise

Hands-on

Layer Week 1's Greeting service behind an interface

Take the greeting endpoint you built in Week 1 and turn it into a small, properly-layered example of everything covered this week: an interface-backed bean, constructor injection, two competing implementations, and a lifecycle callback you can watch fire.

Requirements:

  1. Define a GreetingService interface with one method, String greet(String name).
  2. Create a FriendlyGreetingService implementation annotated @Service and @Primary, returning something like "Hey there, " + name + "!".
  3. Create a second implementation, FormalGreetingService, annotated @Service("formalGreetingService"), returning "Good day, " + name + "." — do not mark it @Primary.
  4. Update GreetingController to depend on the GreetingService interface (not a concrete class) via constructor injection. Add a second constructor parameter using @Qualifier("formalGreetingService") to also inject the formal variant, and expose it on a second endpoint, e.g. GET /greet/formal, so both implementations are demonstrably wired into the same controller at once.
  5. Add a @PostConstruct method to FriendlyGreetingService that logs a line proving the bean was constructed (e.g. "FriendlyGreetingService bean created"), and confirm it prints once at startup — not once per request.
Hint

If you inject a bare GreetingService parameter with no @Qualifier and both implementations exist, Spring uses whichever one is marked @Primary to resolve the ambiguity. @Qualifier("beanName") is how you deliberately ask for the non-primary one instead — you'll need both approaches in the same controller for this exercise.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why is constructor injection preferred over field injection (@Autowired on a private field) in idiomatic Spring code?

Constructor injection lets dependency fields be declared final, so once the object exists its collaborators can never be silently swapped or left null after construction. It also means unit tests can build the class with a plain new call and hand-written fakes, with no Spring container or reflection involved, which keeps tests fast and simple. And because the constructor demands every required dependency up front, a missing bean causes the application to fail immediately at startup with a clear error, rather than deploying successfully and throwing a NullPointerException the first time a request happens to touch the unset field. Field injection hides all of these problems: it works via reflection after construction, so an object can exist in a half-wired, untestable state, and tests need a running Spring context or special test hooks just to populate a private field.

Q2

What does @ConditionalOnClass do in an auto-configuration class, and why does it matter for how starters work?

@ConditionalOnClass tells Spring Boot to only register a particular auto-configuration class if a specified class is actually present on the classpath at runtime. This is what makes starters work as an all-or-nothing bundle: spring-boot-starter-web pulls in the Tomcat and Spring MVC jars as transitive dependencies, and because those classes are now on the classpath, the matching auto-configuration classes (guarded by @ConditionalOnClass(Tomcat.class) and similar) activate automatically and register beans like the embedded servlet container. If that starter were removed, those classes would no longer be present, the condition would fail, and the auto-configuration would silently skip itself instead of registering beans for a library that isn't even on the classpath — no error, just quietly not wiring in a capability you didn't ask for.

Q3

What's the practical difference between singleton and prototype bean scope, and when would you actually reach for prototype?

A singleton bean, the default, is created once by the container and that same instance is handed out every time it's needed anywhere in the application — appropriate for the vast majority of beans, like services and repositories, that hold no per-use mutable state of their own. A prototype-scoped bean gets a brand-new instance constructed every single time it's requested from the container, so no state leaks between different callers. You'd reach for prototype specifically when a bean accumulates mutable state across a sequence of method calls that must not be shared or corrupted by concurrent use, such as a stateful builder object being filled in over several steps. Reaching for prototype scope by default, rather than only when a bean genuinely needs fresh per-use state, is a common mistake — most beans should stay singleton.

Q4

Why does it matter which package your @SpringBootApplication class lives in, relative to your @Service and @Repository classes?

@SpringBootApplication includes @ComponentScan, which by default only scans the package containing the annotated class and every sub-package beneath it — it does not scan sibling packages or anything above it. That's why every generated Spring Boot project places its application class at the root package, one level above every other package in the project: it guarantees every @Service, @Repository, and @Component class anywhere in the codebase sits inside that scanned subtree. If the application class were moved into a leaf package instead, any stereotype-annotated class outside that leaf's subtree would simply never be registered as a bean, with no error raised — the failure would only surface later as a confusing "no qualifying bean" exception wherever something tried to inject it.