Dependency Injection in Spring Boot 4
How VehicleValuationService gets its repository and provider clients without ever constructing them itself — and why Boot 4 finds them faster than ever.
In the previous lesson, VehicleValuationService showed up already holding a VehicleRepository and three ValuationProviderClient implementations, and none of those were created with a new keyword inside the class. That's dependency injection (DI): instead of a class reaching out and constructing the things it depends on, those things are handed to it from the outside — 'injected' — by a container that owns the job of wiring the whole application graph together. Spring's container, the ApplicationContext, is that assembler. It builds every bean your app declares, figures out which beans depend on which others, and constructs them in the right order.
Spring Boot supports three injection styles — constructor, field, and setter — but constructor injection is the only one you should reach for by default, and Boot 4 doesn't just recommend it, it leans on it. Field injection (@Autowired lateinit var repository: VehicleRepository) looks convenient, but it means the class can be instantiated in a half-built state with null dependencies, it makes the class impossible to construct manually in a unit test without reflection tricks or a full Spring context, and it hides required dependencies from anyone reading the class's public surface. Constructor injection makes every dependency a val in the primary constructor: the object literally cannot exist without them, the compiler enforces immutability, and a plain unit test can write VehicleValuationService(fakeRepo, fakeBlackBook, fakeCarfax, fakeVis) with zero Spring involved.
Constructor injection also buys you something field injection cannot: fail-fast circular dependency detection at startup. If VehicleValuationService depended on some ProviderHealthMonitor bean, and that monitor depended back on VehicleValuationService, a constructor-based cycle is structurally impossible to satisfy — Spring throws a clear BeanCurrentlyInCreationException the moment you start the app, telling you exactly which beans are involved. With field or setter injection, Spring can sometimes paper over such a cycle by injecting a partially-initialized proxy, which trades a loud startup failure for a subtle runtime bug that only shows up once someone actually calls the half-built bean.
Spring finds most of your beans through stereotype annotations, which are really just semantic aliases for @Component: @RestController marks VehicleController as a web-layer bean whose methods are mapped to HTTP routes, @Service marks VehicleValuationService as a business-logic bean, and @Repository marks data-access beans (though with Spring Data JPA, the interface itself is enough — Spring generates the implementing bean for you). These annotations don't change how DI works mechanically; they document intent and, in @Repository's case, enable automatic translation of database-specific exceptions into Spring's consistent DataAccessException hierarchy.
Stereotypes only work for classes you own and can annotate. BlackBookClient, CarfaxClient, and VisClient might wrap a third-party HTTP SDK, or your Redis client, or an ObjectMapper configured with specific settings — types that live in a library and cannot have @Service slapped on them. For those, you write an @Configuration class with @Bean-annotated factory methods: a method named blackBookClient() that constructs and returns a configured BlackBookClient, which Spring then treats exactly like any other bean and can inject wherever it's needed. This is the escape hatch that keeps DI working uniformly across both your code and everything you didn't write.
Spring Boot 4 changes how much of this wiring happens through reflection-heavy classpath scanning versus explicit registration. Classic Spring Boot scans the classpath at startup, finds every @Component-family annotation, and reflectively builds a bean definition for each — flexible, but it costs real startup time and memory, especially as an app grows. Boot 4 pushes hard on functional bean registration (registering beans via explicit code, like registerBean calls, rather than annotation scanning) combined with Ahead-of-Time (AOT) processing, where Spring analyzes your bean graph at build time and generates most of the wiring code up front instead of discovering it by scanning classes at every startup. The stereotype annotations you write still work the same way from your perspective — you still write @Service and constructor parameters — but under the hood, Boot 4 can resolve much of that graph before the JVM even starts the app, which is what makes AOT-processed and native-image builds start dramatically faster.
None of this changes the mental model you need day to day: depend on abstractions through your constructor, let Spring supply the concrete instances, and never instantiate a collaborator yourself inside a Spring-managed bean. Whether the container assembles VehicleValuationService via classic reflection or via AOT-generated code, the class itself looks identical — a primary constructor listing everything it needs, nothing hidden, nothing optional that shouldn't be.
@Serviceclass VehicleValuationService(private val repository: VehicleRepository,private val blackBook: BlackBookClient,private val carfax: CarfaxClient,private val vis: VisClient,) {// No @Autowired needed on the constructor: Spring picks the single// constructor automatically when there is only one.}// Contrast: field injection, which this codebase avoids.// class VehicleValuationServiceBad {// @Autowired// lateinit var repository: VehicleRepository // nullable window before injection runs// }
Constructor injection: every collaborator is a val in the primary constructor, so the class cannot exist without them. Requires a real Spring context to actually be instantiated by the container, so it does not run in-browser.
@Configurationclass ProviderClientConfig {@Beanfun blackBookClient(@Value("\${providers.black-book.base-url}") baseUrl: String,httpClient: OkHttpClient,): BlackBookClient = BlackBookClient(baseUrl, httpClient)@Beanfun httpClient(): OkHttpClient = OkHttpClient.Builder().callTimeout(Duration.ofSeconds(5)).build()}
A @Bean method for a third-party type you don't own and cannot annotate with a stereotype. Requires a Spring ApplicationContext to process the @Configuration class, so it does not run in-browser.
class VehicleValuationServiceTest {private val repository = mockk<VehicleRepository>()private val blackBook = mockk<BlackBookClient>()private val carfax = mockk<CarfaxClient>()private val vis = mockk<VisClient>()private val service = VehicleValuationService(repository, blackBook, carfax, vis)@Testfun `valuate returns a decision built from all three providers`() = runTest {coEvery { repository.findActiveRulesFor(any()) } returns emptyList()coEvery { blackBook.quote(any()) } returns Quote(1200_00)coEvery { carfax.quote(any()) } returns Quote(1150_00)coEvery { vis.quote(any()) } returns Quote(1180_00)val result = service.valuate("1HGCM82633A004352")assertEquals("APPROVED", result.decision)}}
Because dependencies arrive via the constructor, a unit test can build the service with fakes and zero Spring involvement. Uses MockK, which requires the project's test classpath, so it does not run in-browser.
🧠 Check your understanding
0/1 · 0/1 answered1. A teammate refactors VehicleValuationService to use field injection with @Autowired lateinit var properties instead of constructor injection, arguing it reduces boilerplate in the class header. What is the strongest technical objection to this change?