Command Palette

Search for a command to run...

[Spring Boot Basics] @Configuration and @Bean in Spring: When to Use a Factory Method Instead of a Stereotype

@Component and its specialisations have one hard requirement that nobody mentions: you must own the source file. The annotation has to be written into the class, which means the class has to be one you can edit. The moment you need a bean of a type that arrived on the classpath inside somebody else's jar, that route is closed and no amount of component scanning will open it.

@Bean is the answer to exactly that problem, and the rest of this article is what follows from it: where the boundary between the two styles actually sits, and the proxyBeanMethods flag that decides whether a call from one @Bean method to another returns the existing singleton or quietly builds a second object. Every number and every error message below is copied from a terminal running Spring Boot 4.1.1.

Two routes into the container: @Component and @Bean

The toolchain is Spring Boot 4.1.1 (Spring Framework 7.0.9) on OpenJDK 21.0.6, built with Gradle 9.7.1 through the wrapper, and the third-party library used as the running example is Caffeine 3.2.4.

@Component only works on classes you own

Here is the wall, in its most ordinary form. The application needs an in-memory cache, so Caffeine goes into build.gradle:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	implementation 'com.github.ben-manes.caffeine:caffeine'
}

No version — Caffeine is in the Spring Boot BOM, so the build resolves one for you:

Bash
./gradlew dependencies --configuration compileClasspath
Text
\--- com.github.ben-manes.caffeine:caffeine -> 3.2.4

Now try to make a Cache bean the way you make every other bean. The type you want is this:

Bash
unzip -l ~/.gradle/caches/modules-2/files-2.1/com.github.ben-manes.caffeine/caffeine/3.2.4/*/caffeine-3.2.4.jar | grep "cache/Cache"
Text
     1965  02-01-1980 00:00   com/github/benmanes/caffeine/cache/Cache.class

A .class file. Not a .java file — a compiled class inside a read-only archive in the Gradle cache. There is nowhere to type @Component, and the workarounds are all worse than the problem:

  • Fork the library and add the annotation. You now maintain a fork of Caffeine forever, and every upgrade is a merge.
  • Subclass it. Cache is an interface whose implementations are package-private and produced by a builder. Even where subclassing is possible, it makes your application's bean a type the library never intended to be extended.
  • Hold it in a static field. That is the global variable the container exists to replace, and it is untestable for the same reason.
  • Wrap it in your own @Component. Legitimate sometimes, but it means every caller talks to your wrapper instead of the library's API, and you have written a class whose only content is a constructor call.

The framework's actual answer is a factory method: a method you write, on a class the container reads, whose return value becomes the bean. You own the method even though you do not own the class it returns.

A class you own takes @Component and is scanned; a class in a jar arrives through a @Bean factory method — both end in the same registry

@Bean factory methods

A @Bean method is a method on a @Configuration class. The container calls it once, takes whatever it returns, and registers that object as a bean. What happens inside the method is ordinary Java — a builder, an if, a loop, a call to a factory the library ships.

src/main/java/com/example/demo/cache/CacheConfig.java
package com.example.demo.cache;
 
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
@Configuration
public class CacheConfig {
 
    @Bean
    public Cache<String, Product> productCache() {
        return Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(Duration.ofMinutes(30))
                .recordStats()
                .build();
    }
}

Three details in those eight lines:

The method name becomes the bean name. productCache() registers a bean called productCache. There is no separate naming step and no string to keep in sync — rename the method and the bean is renamed with it.

@Bean(name = "...") overrides that, and it replaces the method name rather than adding to it. A method userCache() annotated @Bean(name = "userSessions") produces a bean named userSessions and no bean named userCache.

The declared return type is what the container indexes. Cache is an interface; Caffeine's builder returns some package-private implementation class. Declaring the method as returning Cache means the container matches injection points against the interface, and the implementation class stays an implementation detail — the same discipline as programming to an interface anywhere else.

Injecting it is completely ordinary. The consuming class is one you do own, so it gets a stereotype and constructor injection:

src/main/java/com/example/demo/cache/CatalogService.java
package com.example.demo.cache;
 
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.stereotype.Service;
 
@Service
public class CatalogService {
 
    private final Cache<String, Product> productCache;
 
    public CatalogService(Cache<String, Product> productCache) {
        this.productCache = productCache;
    }
 
    public Product find(String sku) {
        return productCache.get(sku, key -> new Product(key, "loaded from the database"));
    }
 
    public String stats() {
        return productCache.stats().toString();
    }
}

Calling find twice and printing the cache statistics shows the bean is a real, shared, configured Caffeine cache and not a fresh one per call:

Text
find(A-1)      = Product[sku=A-1, name=loaded from the database]
find(A-1)      = Product[sku=A-1, name=loaded from the database]
stats          = CacheStats{hitCount=1, missCount=1, loadSuccessCount=1, loadFailureCount=0, totalLoadTime=12667, evictionCount=0, evictionWeight=0}
productCache   = true
userCache      = false
userSessions   = true

One miss, one hit. The last three lines are containsBean checks and they confirm the naming rules: productCache exists, and userCache — the method name of a @Bean(name = "userSessions") method — does not.

Registering one class several times

A cache is not one thing. Product data can sit for half an hour; a session cache must not. Two @Bean methods, same class, different configuration:

Java
@Bean
public Cache<String, Product> productCache() {
    return Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(30))
            .recordStats()
            .build();
}
 
@Bean(name = "userSessions")
public Cache<String, User> userCache() {
    return Caffeine.newBuilder()
            .maximumSize(500)
            .expireAfterWrite(Duration.ofMinutes(5))
            .build();
}

This is the case a stereotype cannot express at all. @Component says "this class is one bean"; there is no way to say "this class is three beans with different constructor arguments". A factory method has no such limit, because a method can be written as many times as you like.

Note that the two methods here return different generic types, Cache<String, Product> and Cache<String, User>, and Spring resolves generics when it matches injection points — so CatalogService asking for Cache<String, Product> gets the right one with nothing extra. When two beans really do share a type, telling the container which to inject is the job of @Qualifier and @Primary, covered in the previous article on dependency injection.

Stereotype or @Bean: which one?

The boundary is not a matter of taste. It follows from what each mechanism can physically do:

SituationUseWhy
You own the class and new is all that is needed@Component / @Service / @RepositoryThe scan finds it, the constructor is the only construction logic, and there is nothing for a factory method to add.
The class is compiled into a third-party jar@BeanThere is no source file to annotate. This is the case with no alternative.
The class comes from the JDK — Clock, HttpClient, ObjectMapper@BeanSame reason. You will never annotate java.time.Clock.
Construction needs real logic — a builder, a timeout, a branch on a property@BeanA method body can contain anything. An annotation cannot.
One class must become several beans with different settings@BeanA stereotype gives exactly one bean per class.
You are adapting something into the container — a FactoryBean, a library's own factory, an object built by a static method@BeanFactory methods are the adapter layer between a library's construction API and the container.
The bean is a BeanFactoryPostProcessor or similar container infrastructurestatic @BeanIt has to exist before ordinary beans do. See below.
It is your own class but needs a value that only exists at runtimeEitherA stereotype with @Value on the constructor is usually cleaner; a @Bean method is better when the value drives a branch.

Two realistic examples, one of each.

Stereotype. CatalogService above. You wrote it, its constructor takes its collaborators, and there is no construction logic beyond assigning fields. Writing a @Bean method for it would mean maintaining a second place that has to change every time the constructor does — pure cost, no benefit.

@Bean. The Caffeine cache. You did not write Cache, it is built by a fluent builder rather than a constructor, its settings are a business decision that belongs in your codebase, and you need two of them. Every one of those points is a reason the stereotype route cannot work.

The rule of thumb that falls out: a stereotype is a declaration that a class is a bean; a @Bean method is a recipe for producing one. If there is no recipe worth writing, do not write one.

How one @Bean method depends on another

Beans depend on beans, so factory methods need a way to reach each other. There are two forms, and they are not equivalent.

Declare a parameter. The container resolves it from the context exactly as it resolves a constructor argument:

Java
@Bean
public Reporter reporter(Meter meter) {
    return new Reporter(meter);
}

Call the other method directly. It reads like an ordinary Java call, because syntactically that is what it is:

Java
@Bean
public Reporter reporter() {
    return new Reporter(meter());
}

The second form is the one that surprises people, because whether it is an ordinary Java call depends on a flag you have probably never set.

proxyBeanMethods and the second object you did not ask for

Here is the experiment. Three plain classes, one of which prints its own identity hash code when constructed:

Java
public class Meter {
    public Meter() {
        System.out.println("  Meter constructed -> " + System.identityHashCode(this));
    }
}
 
public class Reporter {
    public final Meter meter;
    public Reporter(Meter meter) { this.meter = meter; }
}
 
public class Auditor {
    public final Meter meter;
    public Auditor(Meter meter) { this.meter = meter; }
}

A configuration class in which two @Bean methods call a third directly:

MetricsConfig.java
@Configuration
public class MetricsConfig {
 
    @Bean
    public Meter meter() {
        return new Meter();
    }
 
    @Bean
    public Reporter reporter() {
        return new Reporter(meter());
    }
 
    @Bean
    public Auditor auditor() {
        return new Auditor(meter());
    }
}

And a runner that prints what the container ended up with:

Java
@Bean
public CommandLineRunner report(MetricsConfig config, Meter meter, Reporter reporter, Auditor auditor) {
    return args -> {
        System.out.println("config class   = " + config.getClass().getName());
        System.out.println("meter bean     = " + System.identityHashCode(meter));
        System.out.println("reporter.meter = " + System.identityHashCode(reporter.meter));
        System.out.println("auditor.meter  = " + System.identityHashCode(auditor.meter));
    };
}

Read the Java and you would expect three new Meter() calls: one for the meter bean, one inside reporter(), one inside auditor(). What actually happens:

Text
  Meter constructed -> 1467977993
config class   = com.example.demo.full.MetricsConfig$$SpringCGLIB$$0
meter bean     = 1467977993
reporter.meter = 1467977993
auditor.meter  = 1467977993

One construction. One object. Three references to it.

The second line is the mechanism. config.getClass() is not MetricsConfig — it is MetricsConfig$$SpringCGLIB$$0, a subclass generated at runtime. That is what @Configuration means by default: full mode, in which the class is subclassed by CGLIB and every @Bean method is overridden with an interceptor. When reporter() calls meter(), it is calling the override, and the override asks the container for the meter bean instead of running the method body. The body ran exactly once, when the container created the bean.

Adding two more lines to the runner shows the subclass is genuinely a subclass:

Text
config class   = com.example.demo.full.MetricsConfig$$SpringCGLIB$$0
  extends      = com.example.demo.full.MetricsConfig
  implements   = [interface org.springframework.context.annotation.ConfigurationClassEnhancer$EnhancedConfiguration]

That EnhancedConfiguration marker interface is how Spring recognises a configuration class it has already enhanced. The $$SpringCGLIB$$ in the name is worth memorising — it turns up in stack traces, in toString() output and in logs, and it means "you are looking at a proxied configuration class", not at a bug.

Full mode intercepts the inter-bean call and returns the singleton; lite mode lets the method body run again

proxyBeanMethods = false

@Configuration has a flag that turns the subclassing off. The class is otherwise unchanged — same three methods, same direct calls:

MetricsConfig.java
@Configuration
@Configuration(proxyBeanMethods = false) 
public class MetricsConfig {
    // identical body
}

Same runner, same application, one flag different:

Text
  Meter constructed -> 1857007886
  Meter constructed -> 1179272258
  Meter constructed -> 1002911155
config class   = com.example.demo.lite.MetricsConfig
meter bean     = 1857007886
reporter.meter = 1179272258
auditor.meter  = 1002911155

Three constructions. Three different objects. The Meter inside Reporter is not the Meter bean. This is lite mode: no subclass — config.getClass() is now plainly MetricsConfig — so meter() is an ordinary method call that runs its body every time. The container still has exactly one meter bean; the other two objects simply are not beans at all. Nothing fails, nothing is logged, and every piece of state you thought was shared is now duplicated.

(The two runs above live in packages com.example.demo.full and com.example.demo.lite so both configurations can sit in one project and be started as two separate applications. Nothing else differs between them.)

Lite mode is not a trap for its own sake. Full mode costs something real: a CGLIB subclass has to be generated, loaded and instantiated for every @Configuration class at startup, and every inter-bean call goes through an interceptor rather than the JVM's own call. It also means the configuration class cannot be final and neither can its @Bean methods, which is friction in Kotlin and an obstacle for ahead-of-time and native-image builds that prefer no runtime class generation.

Spring Boot itself chooses lite mode everywhere it can. @AutoConfiguration, the annotation on every auto-configuration class in the framework, carries it — javap -v on the annotation prints:

Text
RuntimeVisibleAnnotations:
  3: org.springframework.context.annotation.Configuration(
      proxyBeanMethods=false
     )

So the hundreds of @Bean methods Boot runs on your behalf are all in lite mode. (That auto-configuration is nothing more than @Bean methods guarded by conditions is the subject of the last article in this series.)

The rule for using lite mode safely is one line: in a lite-mode configuration class, never call another @Bean method directly — declare it as a parameter instead. The parameter form goes through the container in both modes, so it is correct regardless of the flag. Same lite-mode class, rewritten:

MetricsConfig.java
@Configuration(proxyBeanMethods = false)
public class MetricsConfig {
 
    @Bean
    public Meter meter() {
        return new Meter();
    }
 
    @Bean
    public Reporter reporter(Meter meter) {
        return new Reporter(meter);
    }
 
    @Bean
    public Auditor auditor(Meter meter) {
        return new Auditor(meter);
    }
}
Text
  Meter constructed -> 1857007886
config class   = com.example.demo.params.MetricsConfig
meter bean     = 1857007886
reporter.meter = 1857007886
auditor.meter  = 1857007886

One construction again, with no proxy involved. That is why the parameter form is the better default even in full mode: it says what it means, it works in both modes, and it survives somebody adding proxyBeanMethods = false to the class a year from now.

@Bean methods on a plain @Component class

@Bean is not restricted to @Configuration classes. Put the same methods on a @Component and the application starts perfectly:

MetricsBeans.java
@Component
public class MetricsBeans {
 
    @Bean
    public Meter meter() { return new Meter(); }
 
    @Bean
    public Reporter reporter() { return new Reporter(meter()); }
 
    @Bean
    public Auditor auditor() { return new Auditor(meter()); }
}
Text
  Meter constructed -> 645643802
  Meter constructed -> 949314262
  Meter constructed -> 559998250
config class   = com.example.demo.compcfg.MetricsBeans
meter bean     = 645643802
reporter.meter = 949314262
auditor.meter  = 559998250

Three objects. A @Bean method on any class that is not a full-mode @Configuration class behaves as lite mode — the same duplication, with nothing in the source to warn you. That is the practical reason to keep bean definitions on classes annotated @Configuration: the annotation is the signal to every reader, and to Spring, that inter-bean calls are meant to be honoured.

static @Bean methods are never proxied

One more case where full mode does not save you. A static method cannot be overridden, so CGLIB cannot intercept it — even inside a default @Configuration class:

StaticConfig.java
@Configuration
public class StaticConfig {
 
    @Bean
    public static Meter meter() { return new Meter(); }
 
    @Bean
    public Reporter reporter() { return new Reporter(meter()); }
}
Text
  Meter constructed -> 1835316563
  Meter constructed -> 490630452
meter bean     = 1835316563
reporter.meter = 490630452

Two objects, in full mode, from a class with no flags set.

static is nevertheless required in one situation: a @Bean method returning a BeanFactoryPostProcessorPropertySourcesPlaceholderConfigurer being the one people meet — because that bean has to be created before the container can process annotations on the configuration class that declares it. Leave the static off and Spring tells you so at startup:

Text
INFO o.s.c.a.ConfigurationClassEnhancer : @Bean method PlaceholderConfig.placeholders is non-static
and returns an object assignable to Spring's BeanFactoryPostProcessor interface. This will result in
a failure to process annotations such as @Autowired, @Resource, and @PostConstruct within the
method's declaring @Configuration class. Add the 'static' modifier to this method to avoid these
container lifecycle issues; see @Bean javadoc for complete details.

It is logged at INFO, so it scrolls past unnoticed. Outside that narrow case, do not make @Bean methods static — you lose interception and gain nothing.

@Import, and configuration classes as beans

Configuration does not have to live in one class, and it does not have to be found by the scan. @Import names another configuration class explicitly:

CacheConfig.java
@Configuration(proxyBeanMethods = false)
@Import(ClockConfig.class)
public class CacheConfig {
    // ...
}
ClockConfig.java
@Configuration(proxyBeanMethods = false)
public class ClockConfig {
 
    @Bean
    public Clock clock() {
        return Clock.system(ZoneOffset.UTC);
    }
}
Text
clock          = java.time.Clock$SystemClock

The Clock bean is registered even though ClockConfig was never scanned for. That matters in two places: a configuration class that lives outside your base package (a shared library, typically) and a configuration class you deliberately keep out of the scan so it can be pulled in only where it is wanted. It is also, incidentally, a second illustration of the article's opening point — java.time.Clock is an abstract class in the JDK, and Clock.system(...) is a static factory. There is no annotation you could ever put on it.

Worth stating plainly, because it explains several behaviours at once: a @Configuration class is itself a bean. That is why the runner above could take MetricsConfig config as a parameter and print its class. It can have a constructor with dependencies, it can be injected elsewhere, and in full mode the bean in the container is the CGLIB subclass rather than the class you wrote.

Three failures worth recognising

A @Bean method on a class nobody registered

@Bean does nothing on its own. The class carrying the method has to reach the container — by being scanned, by being imported, or by being the application class. Forget that and the method is just a method:

src/main/java/com/example/demo/forgot/RegistryConfig.java
package com.example.demo.forgot;
 
// no annotation here at all
public class RegistryConfig {
 
    @Bean
    public Clock clock() {
        return Clock.system(ZoneOffset.UTC);
    }
}

The failure surfaces somewhere else entirely — at whatever tried to inject the bean:

Text
***************************
APPLICATION FAILED TO START
***************************
 
Description:
 
Parameter 0 of constructor in com.example.demo.forgot.TimeService required a bean of type
'java.time.Clock' that could not be found.
 
 
Action:
 
Consider defining a bean of type 'java.time.Clock' in your configuration.

"Consider defining a bean" is misleading when you have already defined one. The check is always the same: the class holding the @Bean method is missing @Configuration, or it is in a package the scan never reaches.

A final @Configuration class

Full mode subclasses the class, and a final class cannot be subclassed. Spring detects this before it tries:

FinalConfig.java
@Configuration
public final class FinalConfig {
 
    @Bean
    public Clock clock() { return Clock.system(ZoneOffset.UTC); }
}
Text
org.springframework.beans.factory.parsing.BeanDefinitionParsingException: Configuration problem:
@Configuration class 'FinalConfig' may not be final. Remove the final modifier to continue.
Offending resource: class path resource [com/example/demo/finalcfg/FinalConfig.class]
	at org.springframework.context.annotation.ConfigurationClass.validate(ConfigurationClass.java:251)
	at org.springframework.context.annotation.ConfigurationClassPostProcessor.processConfigBeanDefinitions

Individual methods have the same constraint, with its own message:

Text
Configuration problem: @Bean method 'clock' must not be private or final; change the method's
modifiers to continue.

Removing final is the usual fix. Adding proxyBeanMethods = false also works — a lite-mode configuration class is never subclassed, so final is allowed and the application starts — but only take that route if the class has no inter-bean calls to break.

Two @Bean methods with the same bean name

Two configuration classes, each declaring a bean named clock, is an easy accident: one is yours, one came from a library, or two people added the same helper a week apart.

Java
@Configuration
public class AuditConfig {
    @Bean
    public Clock clock() { return Clock.system(ZoneOffset.UTC); }
}
 
@Configuration
public class BillingConfig {
    @Bean
    public Clock clock() { return Clock.system(ZoneId.of("Asia/Ho_Chi_Minh")); }
}

Spring Boot's default is to refuse:

Text
***************************
APPLICATION FAILED TO START
***************************
 
Description:
 
The bean 'clock', defined in class path resource [com/example/demo/dup/BillingConfig.class], could
not be registered. A bean with that name has already been defined in class path resource
[com/example/demo/dup/AuditConfig.class] and overriding is disabled.
 
Action:
 
Consider renaming one of the beans or enabling overriding by setting
spring.main.allow-bean-definition-overriding=true

This is a good default, and the "Action" is a trap. Take it and the application starts, one definition silently replaces the other, and all you get is a line at INFO:

Text
INFO o.s.b.f.s.DefaultListableBeanFactory : Overriding bean definition for bean 'clock' with a
different definition: replacing [... factoryBeanName=auditConfig; factoryMethodName=clock ...]
with [... factoryBeanName=billingConfig; factoryMethodName=clock ...]
Text
clock zone = Asia/Ho_Chi_Minh

The application is now running on whichever definition happened to be registered last — an ordering that depends on class scanning and can change when you rename a file. Rename one of the beans instead.

⚠️ spring.main.allow-bean-definition-overriding=true makes a startup failure disappear by converting it into a behaviour that depends on scan order. Treat a BeanDefinitionOverrideException as a naming bug to fix, not a setting to change.

FAQ

Can I put @Bean methods on my @SpringBootApplication class?

Yes. @SpringBootApplication includes @SpringBootConfiguration, which is @Configuration, so the application class is a full-mode configuration class and @Bean methods on it work exactly as described here. It is fine for one or two beans in a small service; past that, move them into named configuration classes so the application class stays a launcher.

Why does my @Bean method run twice?

Because something is calling it directly in lite mode. Either the class is @Configuration(proxyBeanMethods = false), or the methods are on a @Component rather than a @Configuration class, or the called method is static. Print System.identityHashCode on both sides to confirm, then convert the direct call into a method parameter, which is correct in every mode.

Should I set proxyBeanMethods = false on all my configuration classes?

Only if you also follow the rule that goes with it: no direct calls between @Bean methods. In a library or starter — where startup cost is multiplied across every application that depends on it, and native-image support matters — it is the right default, and it is what Spring Boot's own @AutoConfiguration does. In ordinary application code the saving is negligible and the risk of a future direct call is not, so leaving the default alone is defensible.

Can a @Bean method be private, final or static?

Not private or final in a full-mode class — Spring fails at startup with "must not be private or final; change the method's modifiers to continue", because it cannot override them. static is allowed but never intercepted, so direct calls to a static @Bean method build a new object every time even in full mode. Use static only for BeanFactoryPostProcessor beans, which must exist before the container processes the declaring class.

Does @Bean work on a class that is not annotated at all?

No. The method is invisible until the class itself is a bean definition — reached by component scanning, by @Import, or by being the application class. The symptom is not an error on the configuration class but a required a bean of type ... that could not be found at the injection point, which sends people looking in the wrong file.

How do I register two beans of the same type from one configuration class?

Write two methods. Give them distinct names — the method name is the bean name — or set @Bean(name = "...") explicitly. If the two beans have different generic parameters, such as Cache<String, Product> and Cache<String, User>, injection by type already tells them apart; if they are the same type, mark one @Primary or select with @Qualifier.

Conclusion

@Component registers a class you own; @Bean registers an object you produce. That is the whole boundary, and everything else follows from it — third-party types, JDK types, builders, branching construction logic and one class registered several times are all cases where there is no source file to annotate or no single answer to annotate it with. Bean names come from method names unless @Bean(name = ...) says otherwise, @Import pulls in configuration the scan never sees, and a @Configuration class is itself a bean.

The part that costs people real time is proxyBeanMethods. By default @Configuration is full mode: the class is subclassed by CGLIB, a direct call from reporter() to meter() is intercepted, and all three references point at the same object — 1467977993, three times. Set proxyBeanMethods = false, or move the methods to a @Component, or make one of them static, and the same source produces 1857007886, 1179272258 and 1002911155: three objects, no error, no warning. The defence is to stop relying on interception at all and declare inter-bean dependencies as method parameters, which behaves identically in both modes.

The next article stays inside the container and asks how long a bean lives: bean scope and lifecycle — singleton, prototype, request and session, and the @PostConstruct and @PreDestroy callbacks that run at each end of it.

Related Posts

[Spring Boot Basics] @ConfigurationProperties in Spring Boot: Type-Safe Configuration with Validation

Type-safe configuration in Spring Boot 4.1.1 with @ConfigurationProperties, checked against real runs: binding to records without @ConstructorBinding, JavaBean binding and @DefaultValue, the three ways to register a properties class, nested objects, lists, maps, enums, Duration and DataSize conversion, relaxed binding and environment variable names, @Validated fail-fast startup errors, the configuration processor metadata, and a side-by-side comparison with @Value.

[Spring Boot Basics] JSON with Jackson 3 and DTOs in Spring Boot: Serialization, Deserialization and MapStruct

JSON in Spring Boot 4.1.1 with Jackson 3.1.5, verified on a real project: JacksonJsonHttpMessageConverter and the jacksonJsonMapper bean, the tools.jackson packages, the immutable JsonMapper and unchecked exceptions, measured Jackson 3 defaults against use-jackson2-defaults, @JsonProperty, @JsonIgnore, @JsonInclude, @JsonFormat, BigDecimal, enums and Optional, records, @JsonAlias and @JsonCreator, spring.jackson properties and JsonMapperBuilderCustomizer, why DTOs beat exposing the entity, manual mapping and MapStruct 1.6.3 with Gradle and Maven.

[Spring Boot Basics] IoC and Dependency Injection in Spring: Why You Stop Calling new

The idea the whole framework rests on, demonstrated on Spring Boot 4.1.1 and Java 21: a four-class object graph built with new at every level and the three failures that follow, Inversion of Control and Dependency Injection named separately, the same graph hand-wired in main with no framework at all, then wired by the Spring container with the injected instance identities printed to prove it, plus a JUnit 5 test with a hand-made stub, an implementation swapped without touching its consumer, and an honest list of what the container costs you.

[Spring Boot Basics] Global Exception Handling in Spring Boot: @RestControllerAdvice, @ExceptionHandler and ProblemDetail

Global exception handling in Spring Boot 4.1.1, verified on a real project: the default /error body and BasicErrorController, spring.web.error.* replacing server.error.*, @ResponseStatus and ResponseStatusException, @ExceptionHandler in a controller and in @RestControllerAdvice, how Spring picks one handler by type distance, controller, @Order and cause, ProblemDetail (RFC 9457) and application/problem+json, ErrorResponseException, spring.mvc.problemdetails.enabled, ResponseEntityExceptionHandler with a 422 field error list, and a catch-all that keeps framework 4xx responses.