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. final fields 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, no MockitoAnnotations.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 @Autowired to the constructor. It works, but it’s noise. Since Spring 4.3, a single constructor is auto-detected. Only add @Autowired if 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 @Configuration class per logical domain (SecurityConfig, WebConfig, PersistenceConfig).

  • @Bean methods can take other beans as parameters — Spring resolves them automatically.

  • Prefer @Configuration over @Component for classes containing @Bean methods, because @Configuration uses CGLIB proxies to enforce bean singleton semantics when one @Bean method 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:

  1. @SpringBootApplication expands to @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan.

  2. @EnableAutoConfiguration reads META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports from every JAR on the classpath.

  3. Each listed class is a @Configuration guarded by conditions (@ConditionalOnClass(Servlet.class), etc.).

  4. 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

--server.port=8081

2

SPRING_APPLICATION_JSON env var

JSON blob overrides everything below

3

OS env vars

SPRING_DATASOURCE_URL

4

application-{profile}.yml

Loaded when profile active

5

application.yml

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> or Provider<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

NoSuchBeanDefinitionException on startup

Bean class not in a scanned package

Move class under the @SpringBootApplication class’s package, or add @ComponentScan(basePackages = ...)

@Value("${foo}") injects literal ${foo}

Missing PropertySourcesPlaceholderConfigurer in a legacy hybrid Spring/Boot app

Rare in pure Boot. In hybrid apps, add the bean back.

Two beans of same type: NoUniqueBeanDefinitionException

Auto-config plus your explicit @Bean

Add @Primary on the one you want, or inject as List<T> / Map<String, T>

Beans created twice with different state

@Configuration class also has @Component, or you import the config twice

Remove duplicate registrations. @Configuration alone is enough.

@Value gives null in @PostConstruct

Field injection combined with @PostConstruct timing

Use constructor injection; @Value on constructor params works reliably.


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.

  1. Two implementations, one interface. Create a Notifier interface with EmailNotifier and SmsNotifier implementations. Wire them so a RegistrationService receives both via List<Notifier> and calls each. Then flip to Map<String, Notifier> where the key is the bean name.

  2. Conditional bean. Write a Cache that is enabled only when app.cache.enabled=true, with a noop fallback. Verify with /actuator/beans which one is active.

  3. Config properties. Create a MailProperties record with SMTP host, port, tls flag. Bind from application.yml, inject it, and print it in @PostConstruct.

  4. Force a circular dependency. Watch Boot 3.x fail loudly. Then refactor A and B to depend on a shared C.

  5. Profile switch. Add application-dev.yml and application-prod.yml with different Postgres URLs. Run with -Dspring.profiles.active=dev and confirm via /actuator/env which values won.


Return to README.md · Next: 02_rest_apis_and_web_layer.md