Spring Boot Fundamentals¶
Spring Boot is not “Spring with less config” — that framing is 2015 marketing. Spring Boot 3.4+ (Java 21 baseline) is an opinionated auto-configuration engine that inspects your classpath and application properties and wires up sensible defaults so you don’t spend the first two weeks of every project writing XML that looked the same at your last job. Understand how it does that, and every “magic” bug becomes a mechanical bug. Miss it, and Spring feels like witchcraft forever.
This file is the mental model you need before you touch a @RestController. It’s also the section most tutorials skip — they show you the annotations but not the mechanics underneath. That’s why so many devs “know Spring” for years and still can’t debug a bean creation failure.
1. Dependency Injection: The Non-Negotiable Rules¶
Spring’s core value is the IoC (Inversion of Control) container. You declare what your class needs; Spring provides it. In 2026, the only acceptable form of DI is constructor injection with final fields. Everything else — field injection with @Autowired, setter injection, @Resource, @Inject — is legacy, testable-only-with-reflection, and a smell in code review.
// ✅ THE ONLY WAY
@Service
public class OrderService {
private final OrderRepository repository;
private final PaymentGateway gateway;
private final Clock clock;
// Spring 4.3+: single constructor — @Autowired is IMPLICIT and unnecessary
public OrderService(OrderRepository repository,
PaymentGateway gateway,
Clock clock) {
this.repository = repository;
this.gateway = gateway;
this.clock = clock;
}
}
// ❌ NEVER
@Service
public class OrderService {
@Autowired private OrderRepository repository; // untestable without reflection
@Autowired private PaymentGateway gateway; // mutable, no null-safety
@Autowired private Clock clock; // hidden dependencies
}
Why constructor injection wins:
Immutability.
finalfields enforce that dependencies never change after construction.Explicit dependency graph. A class with 8 constructor args is screaming that it has too many responsibilities. Field injection hides this pain.
Testable without Spring.
new OrderService(mockRepo, mockGateway, fixedClock)works. No@InjectMocks, no reflection, noMockitoAnnotations.openMocks(this).Circular dependencies fail at startup, not runtime. With field injection, Spring 3.x used to silently break cycles with proxies. Now with
spring.main.allow-circular-references=false(the default in Boot 3+), constructor cycles fail loudly — which is what you want.
⚠️ What most people get wrong: They add
@Autowiredto the constructor. It works, but it’s noise. Since Spring 4.3, a single constructor is auto-detected. Only add@Autowiredif you have multiple constructors and need to disambiguate — and if you have multiple constructors, that’s usually a design smell.
@Component, @Service, @Repository, @Controller — do they differ?¶
Technically? @Service and @Component are functionally identical — both register the class as a bean. @Repository adds Spring’s data-access exception translation. @Controller / @RestController add web-layer semantics. In practice, use them as semantic markers so future you can grep. Don’t overthink it.
2. @Configuration and @Bean — When Stereotypes Aren’t Enough¶
Stereotype annotations (@Service etc.) work when you own the class. For third-party classes or wiring logic, use a @Configuration class with @Bean methods:
@Configuration
public class HttpClientConfig {
@Bean
public HttpClient httpClient(@Value("${http.timeout-ms:5000}") int timeoutMs) {
return HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(timeoutMs))
.version(HttpClient.Version.HTTP_2)
.build();
}
@Bean
public Clock clock() {
return Clock.systemUTC(); // injectable, mockable in tests
}
}
Rules of thumb:
One
@Configurationclass per logical domain (SecurityConfig,WebConfig,PersistenceConfig).@Beanmethods can take other beans as parameters — Spring resolves them automatically.Prefer
@Configurationover@Componentfor classes containing@Beanmethods, because@Configurationuses CGLIB proxies to enforce bean singleton semantics when one@Beanmethod calls another.
3. Conditional Beans — The Feature Nobody Teaches First¶
@ConditionalOnProperty, @ConditionalOnClass, @ConditionalOnMissingBean are how Spring Boot’s auto-configuration actually works. They also let you build feature flags cleanly:
@Configuration
public class CacheConfig {
@Bean
@ConditionalOnProperty(name = "app.cache.enabled", havingValue = "true", matchIfMissing = true)
public Cache<String, User> userCache() {
return Caffeine.newBuilder().maximumSize(10_000).build();
}
@Bean
@ConditionalOnMissingBean(Cache.class)
public Cache<String, User> noopCache() {
return new NoopCache<>();
}
}
This pattern replaces most if (properties.isCacheEnabled()) branching. The bean either exists or it doesn’t; downstream code just injects it.
4. Auto-Configuration — How the Magic Actually Works¶
When you add spring-boot-starter-web, HTTP support “just works.” Mechanically:
@SpringBootApplicationexpands to@SpringBootConfiguration+@EnableAutoConfiguration+@ComponentScan.@EnableAutoConfigurationreadsMETA-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importsfrom every JAR on the classpath.Each listed class is a
@Configurationguarded by conditions (@ConditionalOnClass(Servlet.class), etc.).Conditions matched → the config’s beans are registered. Conditions unmatched → skipped silently.
To see what actually happened, run with --debug or hit /actuator/conditions — you get a report of Positive/Negative matches. When “why is this bean missing?” strikes at 2am, this is your answer.
./mvnw spring-boot:run -Dspring-boot.run.arguments=--debug
5. application.yml, Profiles, and Layered Config¶
application.yml beats .properties for anything nested. But the important skill is layered configuration:
# application.yml — defaults
spring:
datasource:
hikari:
maximum-pool-size: 10
app:
feature:
new-checkout: false
---
# application-dev.yml — dev overrides
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:postgresql://localhost:5432/app_dev
---
# application-prod.yml — prod overrides
spring:
config:
activate:
on-profile: prod
datasource:
hikari:
maximum-pool-size: 50
Precedence (highest wins), simplified:
Rank |
Source |
Notes |
|---|---|---|
1 |
Command-line args |
|
2 |
|
JSON blob overrides everything below |
3 |
OS env vars |
|
4 |
|
Loaded when profile active |
5 |
|
Baseline defaults |
Rule: Secrets and environment-specific values come from env vars, never from application-prod.yml committed to Git. See 07_config_and_deployment.md.
Type-safe config with @ConfigurationProperties¶
@ConfigurationProperties(prefix = "app.rate-limit")
public record RateLimitProperties(int requestsPerMinute, Duration windowSize) { }
// In your main app class or a @Configuration:
@EnableConfigurationProperties(RateLimitProperties.class)
app:
rate-limit:
requests-per-minute: 100
window-size: 1m
Now RateLimitProperties is a first-class bean. Inject it, use .requestsPerMinute(), done. No more @Value("${app.rate-limit.requests-per-minute}") int rpm scattered across 15 files.
6. Bean Lifecycle — What You Actually Need to Know¶
The full lifecycle has 10+ callbacks. In practice you’ll use two:
@Component
public class MetricsReporter {
@PostConstruct
void init() {
// Called AFTER dependency injection, BEFORE the bean is exposed
// Use for: warming caches, opening connections, scheduling first task
}
@PreDestroy
void shutdown() {
// Called during graceful shutdown
// Use for: flushing buffers, closing connections
}
}
For AutoCloseable beans, Spring calls close() automatically. Prefer AutoCloseable over @PreDestroy when possible — it works outside Spring too.
Scopes¶
@Singleton(default) — one per container. 99% of your beans.@Scope("prototype")— new instance per injection. Rarely needed.@Scope("request")/@Scope("session")— web-scoped. Almost never right — use plain records passed as method args instead.
⚠️ What most people get wrong: Injecting a prototype-scoped bean into a singleton. You get one instance, cached forever, defeating the point. If you truly need per-call instances, inject an
ObjectProvider<T>orProvider<T>and call.getObject()on it.
7. Circular Dependencies — The #1 Startup Bug¶
@Service
public class A {
private final B b;
public A(B b) { this.b = b; }
}
@Service
public class B {
private final A a;
public B(A a) { this.a = a; }
}
Boot 3+ default: fail-fast at startup with BeanCurrentlyInCreationException. Good. The fix is not to enable spring.main.allow-circular-references=true — that just papers over a design bug. The fix is to extract the shared logic into a third class C that both A and B depend on. Cycles are almost always a signal that responsibilities are wrong.
8. Common Traps and How to Escape Them¶
Symptom |
Actual cause |
Fix |
|---|---|---|
|
Bean class not in a scanned package |
Move class under the |
|
Missing |
Rare in pure Boot. In hybrid apps, add the bean back. |
Two beans of same type: |
Auto-config plus your explicit |
Add |
Beans created twice with different state |
|
Remove duplicate registrations. |
|
Field injection combined with |
Use constructor injection; |
9. A Minimal, Real Skeleton¶
This is the smallest Spring Boot 3.4 app that’s still worth learning from:
// src/main/java/com/example/shortener/ShortenerApplication.java
package com.example.shortener;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class ShortenerApplication {
public static void main(String[] args) {
SpringApplication.run(ShortenerApplication.class, args);
}
}
# src/main/resources/application.yml
spring:
application:
name: shortener
threads:
virtual:
enabled: true # Java 21 virtual threads for Tomcat request handling
server:
port: 8080
shutdown: graceful # let in-flight requests finish
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
endpoint:
health:
probes:
enabled: true # /actuator/health/liveness and /readiness
Notice: virtual threads flipped on with one property, actuator probes turned on, graceful shutdown. This is your baseline for every new service.
Practice Exercises¶
Type these by hand into a Boot 3.4 project. Then break things and read the error messages.
Two implementations, one interface. Create a
Notifierinterface withEmailNotifierandSmsNotifierimplementations. Wire them so aRegistrationServicereceives both viaList<Notifier>and calls each. Then flip toMap<String, Notifier>where the key is the bean name.Conditional bean. Write a
Cachethat is enabled only whenapp.cache.enabled=true, with a noop fallback. Verify with/actuator/beanswhich one is active.Config properties. Create a
MailPropertiesrecord with SMTP host, port, tls flag. Bind fromapplication.yml, inject it, and print it in@PostConstruct.Force a circular dependency. Watch Boot 3.x fail loudly. Then refactor A and B to depend on a shared C.
Profile switch. Add
application-dev.ymlandapplication-prod.ymlwith different Postgres URLs. Run with-Dspring.profiles.active=devand confirm via/actuator/envwhich values won.
Return to README.md · Next: 02_rest_apis_and_web_layer.md