Most of a Spring Boot application is code you did not write. A container builds your objects and decides what each receives; a jar on the classpath adds beans you never declared; configuration arrives from files, environment and command line at once; and your class is usually wrapped in a proxy before anyone calls it. Nearly every baffling failure is a disagreement between what you wrote and what the framework did around it — a value with no visible source, an annotation that silently does not fire, a bean missing or present twice. This guide walks the framework in that order. Claims come from the Spring Boot 4.1 and Spring Framework 7.0 reference documentation; behavior moves between major versions, so check yours.
Each chapter opens with the short version. Tap one to read the detail.
Your main method hands over control
~2 min
SpringApplication.run builds a container that finds your classes, constructs them and connects them. It scans downward from the package of the annotated class, so a component sitting in a sibling package is not a wiring bug — it is invisible.
The container reads bean definitions from your @Component-family classes and @Bean methods, then instantiates and connects them. SpringApplication even picks which kind of context to build from the classpath: a servlet one when Spring's model-view-controller stack (Spring MVC) is present, a reactive one when only WebFlux is, otherwise a plain one. With both present, MVC wins.
@SpringBootApplication implies component scanning, and scanning starts at that class's own package. Drop a @Service into a package beside it, or move the application class down a level, and nothing errors — the bean simply never exists, and every symptom points at whatever wanted it.
The second surprise is that @Bean methods are not ordinary methods. In a @Configuration class ("full" mode) the class is subclassed at runtime, so one @Bean method calling another is intercepted and returns the container's singleton. Declare the same methods in a @Component, or set proxyBeanMethods = false, and you are in "lite" mode: a plain Java call that builds a new object every time.
// com.example.app.Application -> scans com.example.app.**
// com.example.util.Mailer -> @Component here is never found
@Configuration
class Wiring {
@Bean Clock clock() { return Clock.systemUTC(); }
@Bean Billing billing() { return new Billing(clock()); } // the singleton
}
Rule: when a bean is missing, check its package before you check its annotations.
What the container hands you, and what it refuses to
~2 min
Dependencies are matched by type first, with the bean name only as a fallback. So adding a second implementation of an interface is a breaking change: the container that wired one bean happily now fails, because it cannot choose between two.
Prefer constructor injection. The reference recommends it, it lets the fields be final, and a class with exactly one constructor needs no @Autowired at all — with two or more, one must be marked. Because a context pre-instantiates its singletons, a wiring mistake then fails at startup rather than on the first request that touches it.
Ambiguity is the everyday failure. Two beans of one type is not an error until something asks for that type; then it is. @Primary names a default winner and @Qualifier chooses per injection point. If you actually want all of them, ask for the collection: a List<T> receives every bean of that type, ordered by @Order, and a Map<String, T> keys them by bean name.
The mirror image is asking for something absent. @Autowired(required = false), an Optional<T> or a nullable parameter make a dependency genuinely optional; without one, missing means failure.
Circular constructor dependencies cannot be satisfied at all — A needs a finished B that needs a finished A — and the container reports BeanCurrentlyInCreationException as it tries.
interface Gateway {}
@Component @Primary class Card implements Gateway {}
@Component class Legacy implements Gateway {}
@Service class Checkout {
Checkout(Gateway one, List<Gateway> all) {} // Card; then both
}
Rule: publishing a second bean of an injected type changes every injection point — pick the winner deliberately, or take the collection.
How long a bean lives
~2 min
Singleton is the default and means one instance per container, not one per process. The trap is that scope is resolved at the injection point: a prototype injected into a singleton is looked up once, so you hold that "new every time" object forever.
Six scopes exist: singleton and prototype anywhere, plus request, session, application and websocket in web applications. Singleton means one shared instance per container per bean definition — a narrower promise than the classic one-per-process pattern of the same name.
Prototype means a new instance for every request for that bean, with an asymmetry worth memorizing: the container runs initialization callbacks but never destruction callbacks. Whatever it holds open, you close.
The expensive mistake is injecting a prototype into a singleton. Injection happens once, when the singleton is built, so the "fresh" bean is captured and reused for the life of the application — the annotation is honored and the intent is not. Ask for a factory instead: ObjectProvider hands you a new instance per call.
Web scopes have the same shape with a louder failure, because a request-scoped bean does not exist at the moment a singleton is created. That is what a scoped proxy is for: the singleton receives a proxy that resolves the real bean per request.
@Service class Reports {
private final ObjectProvider<Batch> batches; // not a Batch
Reports(ObjectProvider<Batch> batches) { this.batches = batches; }
void run() { batches.getObject().process(); } // a new Batch per call
}
Rule: the injecting bean's scope decides how often the injected bean is looked up.
The classpath is an input
~2 min
Auto-configuration inspects the jars you depend on and adds beans accordingly, so a dependency change alters runtime behavior with no code change. It is non-invasive by design: define your own bean of the same kind and the default steps aside.
A starter is a dependency descriptor — one coordinate bringing in a coherent set of libraries — and the managed version list means you write no versions for them, so upgrading Boot moves them together. Official starters are named spring-boot-starter-*; third-party ones put the project name first, so the prefix keeps meaning something.
@EnableAutoConfiguration, implied by @SpringBootApplication, is the opt-in. Put an embedded database such as H2 on the classpath with no connection settings and you get a DataSource you never declared. Declare a DataSource bean yourself and the embedded default backs away. That backing-off is the whole design: what reads as magic is a pile of conditions evaluated against your classpath and your own beans.
So when behavior changes and your code did not, ask what arrived on the classpath. Start with --debug and Boot logs a conditions report: which auto-configurations matched, which did not, and why. To settle the argument, exclude one by class or by property.
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
class Application {}
// or: spring.autoconfigure.exclude=...
Rule: read the conditions report before you re-read your own code — the framework will tell you what it decided and on what evidence.
Where a value actually came from
~2 min
Configuration is assembled from many sources at once, and later sources override earlier ones. Your packaged application.properties sits near the bottom, with environment variables and command-line arguments above it — so a value that disagrees with the file is usually not a bug.
Boot 4.1 documents its property sources as a list of increasing precedence. The practical shape, bottom to top: defaults, then @PropertySource, then config data such as application.properties, then operating-system environment variables, then Java system properties, then SPRING_APPLICATION_JSON, then command-line arguments, with the test annotations near the top. Config data has an internal order too — files outside the jar beat files packaged inside it, and profile-specific files always override the non-specific ones.
Names are matched loosely, which is the part that bites. demo.item-price also matches demo.itemPrice and the environment variable DEMO_ITEMPRICE; kebab-case is the canonical form to write. An environment variable set two deployments ago can therefore win silently against a file you are editing.
# application.properties, packaged in the jar
demo.item-price=9.99
# beaten by DEMO_ITEMPRICE=1.00, and that beaten by --demo.item-price=2.00
@Value reads a single value with exact matching. @ConfigurationProperties binds a whole subtree onto a typed object with relaxed binding and validation, through the constructor when the class has one parameterized constructor.
Profiles decide which documents and beans apply at all. spring.profiles.active replaces the active set, spring.profiles.include adds to it, and a group lets one name activate several. One rule catches everybody: spring.profiles.active cannot be set inside a profile-specific document.
Rule: when a property is not what the file says, walk the sources above the file before editing the file.
One servlet in front, then your method
~2 min
Every request enters through a single front controller that consults its handler mappings to choose a method. Validation is part of that dispatch: @Valid normally rejects a bad request for you — unless you add an Errors parameter directly after it, which quietly makes rejection your job.
DispatcherServlet is the front controller of the servlet stack. It owns the shared request algorithm and delegates to beans it detects: handler mappings to find the handler, handler adapters to invoke it, exception resolvers, view resolvers. Your controller method is a leaf inside someone else's algorithm, which is why request-wide concerns are configured rather than called.
Validation shows the pattern. Annotate a @RequestBody argument with @Valid and a violation raises MethodArgumentNotValidException before your method runs. Declare an Errors or BindingResult parameter immediately after that argument and the framework hands you the failures instead — your method now runs with invalid input, and if you forget to check, it is accepted. Position is the switch. Constraints placed directly on @RequestParam or @PathVariable parameters take a different route, method validation, and raise HandlerMethodValidationException.
For responses, Spring Framework 7 renders errors as problem details (RFC 9457): return a ProblemDetail, or extend ResponseEntityExceptionHandler in a @ControllerAdvice to shape every built-in exception at once.
// A: Spring rejects it with 400 before this runs
@PostMapping("/orders")
void create(@Valid @RequestBody Order o) { }
// B: same annotation, rejecting is now your job
@PostMapping("/orders")
void create(@Valid @RequestBody Order o, BindingResult r) {
if (r.hasErrors()) { /* omit this and it is accepted */ }
}
Rule: an Errors parameter is a decision to handle validation yourself, never decoration.
The proxy is the annotation
~2 min
@Transactional and its relatives are not compiled into your method; they are applied by a proxy wrapped around your bean. Only calls arriving through that proxy are intercepted, so one method of a class calling another directly runs with no transaction at all.
Spring builds either an interface-based proxy, when your class implements interfaces, or a generated subclass. Both intercept calls from outside; neither can intercept this.otherMethod(), because that call never leaves the object. The documentation is explicit: self-invocation produces no transaction even when the target method is annotated. The fixes, in preference order: restructure so the call crosses a bean boundary, inject the bean into itself and call through that reference, or AopContext.currentProxy() as a last resort.
Subclass proxies inherit the limits of subclassing. A final method cannot be overridden, so it cannot be advised; nor can a private one. Since Spring 6, class-based proxies do honor protected and package-visible methods, while interface-based proxies still see only public ones.
Two defaults finish the picture. Rollback happens for RuntimeException and Error, and a checked exception commits unless you set rollbackFor. And REQUIRED, the default propagation, joins an existing transaction instead of starting a second one — so catching an exception from an inner call does not rescue its work. REQUIRES_NEW suspends the outer transaction and is what actually isolates.
@Service class Orders {
public void place() { charge(); } // internal: no transaction
@Transactional public void charge() { }
}
Rule: when an annotation seems not to fire, ask whether the call came in through the proxy.
A repository you never implemented
~2 min
Boot supplies a DataSource, entity scanning, and repository interfaces whose implementations are generated from their method names. It also holds the persistence context open for the whole web request by default, which is why lazy loading works in a controller and fails everywhere else.
Add the data starter and the container assembles a connection pool, a JdbcClient and JdbcTemplate, and an entity manager; it then searches the auto-configuration packages for repository interfaces and implements them, deriving each query from the method name.
Two defaults do most of the surprising. The first is schema creation: databases for JPA (the Java Persistence API) are created automatically only when they are embedded, such as H2. Local runs therefore build their tables and a real database quietly does not, until you take the schema on deliberately.
The second is spring.jpa.open-in-view, which is on. In a web application Boot registers an interceptor that keeps the entity manager open for the whole request, so a lazy association touched in a controller or during serialization resolves — as extra queries, after your service already returned. Turn the setting off, or run the same code from a scheduled job or a test, and it throws instead. The mapping did not change; the framework was carrying it.
interface Orders extends JpaRepository<Order, Long> {
List<Order> findByCustomerIdOrderByCreatedAtDesc(Long id); // no body
}
Rule: decide open-in-view deliberately — if lazy loading only works inside a request, it is the request holding it up, not your mapping.
A cache is a proxy with a memory
~2 min
@Cacheable skips the method when it has already seen the arguments, and like @Transactional it only works on calls that arrive through the proxy. Boot chooses the store, and with no caching library present that store is an in-process map with no expiry.
Three annotations do three jobs. @Cacheable skips the call on a hit, @CachePut always runs the method and stores the result, and @CacheEvict removes entries. None of them does anything until @EnableCaching sits on a configuration class, and all of them are applied by a proxy, so the self-invocation rule from transactions returns. In a scratch Boot 4.1 application, calling a cached method twice from outside computed once; reaching the same method through this computed twice.
The key is built from every argument by default. A method taking an id and a boolean flag caches each flag value separately, which looks like a cache that rarely hits. Name the identity with key = "#id".
The two filters run at different moments, and their names do not say so. condition is checked before the call and unless after it, so only unless can read #result. A condition written against #result raised no error in that test; it simply never cached. Two defaults also catch people. With no locking, eight threads missing the same entry at once ran the method eight times, and sync = true brought that to one. And eviction runs after the method returns, so a method that throws leaves the stale entry behind unless you set beforeInvocation = true.
Then the store. Boot walks a fixed list of providers and takes the first one it finds, so adding a library can change your cache without any configuration change. With none present it falls back to a ConcurrentHashMap inside the process, which does not survive a restart, is not shared between instances, and never expires anything. The abstraction has no expiry of its own; time-to-live is provider configuration, such as spring.cache.redis.time-to-live.
Rule: name the key, decide what makes an entry stale, and choose the provider on purpose.
A filter chain in front of everything
~2 min
Adding the security starter secures the whole application immediately — every endpoint, including /error and actuator — with one in-memory user whose password is printed in the startup log. Writing your own filter chain replaces the rules, but not that user.
Spring Security is a chain of servlet filters that runs before your controller is reached, which is why an unexpected 401 or 403 has nothing to do with your handler method. With the starter on the classpath and no configuration of your own, Boot secures every endpoint, chooses form login or HTTP basic authentication from the request's Accept header, and creates a single user named user whose generated password is logged at warning level on startup, under a line beginning Using generated security password:, flagged as development-only.
Publish a SecurityFilterChain bean and the default web security configuration backs off in favor of yours. The mistake is reading that as replacing the defaults: it replaces the rules only. It does not switch off the auto-configured user details service, so a careful chain can still be authenticating the generated user. That service backs off for its own reasons — an OAuth2 client or resource server or a SAML2 module on the classpath, or a user details bean of your own.
Boot also ships request matchers for the two moving targets in a chain: actuator endpoints, which follow the configurable management base path, and the usual static-resource locations.
Rule: read the chain before the controller — authentication and authorization finish before your method exists to the request.
The context is the expensive part
~2 min
@SpringBootTest finds your configuration by searching upward from the test's own package and boots a whole application context — but contexts are cached and shared by every test whose configuration matches. A slow suite is usually not slow tests; it is many different contexts.
By default @SpringBootTest starts no server: it loads a web context with a mock environment. RANDOM_PORT and DEFINED_PORT start a real one, and NONE drops the web layer entirely.
Caching is the lever that decides how long the suite takes. The test framework builds a context once per distinct configuration and reuses it, so ten test classes sharing one configuration cost one startup. Anything that changes the configuration — a property override, a mock bean, a different slice — is another context to build.
Slices exist to keep each of those contexts small. @WebMvcTest loads controllers, controller advice, converters, filters and interceptors, and deliberately not your @Service beans, which you supply as mocks. @DataJpaTest does the equivalent for repositories. Two slices cannot be stacked; add the matching @AutoConfigure… annotation instead.
One trap deserves naming: a @Transactional test rolls its work back only when the test and the code under test share a thread. Against a real server on a port they do not — the request runs on the server's own thread in its own transaction, and nothing rolls back.
@WebMvcTest(OrderController.class)
class OrderControllerTests {
@Autowired MockMvcTester mvc;
@MockitoBean OrderService service; // the real @Service is not loaded
}
Rule: choose the smallest context that can still fail for the reason you care about, then reuse it.
Work that leaves the request thread
~2 min
Calling another service, scheduling a job and going reactive are one problem wearing three hats: the work is on a thread whose budget you did not set. Timeouts have no documented defaults, the scheduler runs a single thread, and one blocking call on an event loop stalls every request in flight.
For outbound calls Boot 4.1 auto-configures builders rather than clients — RestClient.Builder for blocking code, WebClient.Builder for reactive, plus declarative interface clients. Timeouts are configuration (spring.http.clients.connect-timeout and read-timeout), and the reference states no defaults for them, so an unresponsive dependency holds your thread for as long as the network allows.
Background work runs on auto-configured executors, and the mistake is hearing "asynchronous" and assuming "in parallel". The task executor starts with eight core threads, while the scheduler behind @Scheduled uses one thread by default, adjustable through spring.task.scheduling.pool.size. Two jobs due at the same moment do not run at the same moment, and one slow job delays every other job. @Async is proxy-based, so the self-invocation rule applies to it exactly as it does to @Transactional.
WebFlux inverts the model: a handful of event-loop threads, typically about one per processor core, serve every request, and a single blocking call there blocks all of them. Its own guidance is blunt — when your persistence and networking libraries block, Spring MVC is the better fit. Mono carries zero or one value and Flux zero to many, with back pressure being the subscriber telling the publisher to slow down.
Rule: every call leaving your process needs a timeout, and every background task needs a thread budget — state both.
Retry is built in now, and off until you enable it
~2 min
Spring Framework 7 ships @Retryable and @ConcurrencyLimit in the core, replacing the archived Spring Retry project. Neither does anything until @EnableResilientMethods is declared, and when retries run out the caller gets the last exception, not a fallback.
Retry used to come from a separate library, Spring Retry, which is now archived and points to the core framework instead. The core version is smaller, and copying older examples is where it goes wrong: @EnableRetry, @Recover and @CircuitBreaker belonged to the old library and have no counterpart in the framework's resilience package. When retries are exhausted, the last exception reaches the caller. There is no recovery method, so the fallback is your own catch.
The annotation is also inert until enabled. In a scratch Boot 4.1 application, a @Retryable method that always threw ran once, and the caller saw the first failure. Adding @EnableResilientMethods to a configuration class made it run four times, the first attempt plus three retries, and the caller saw the fourth failure. Attempts are a second apart by default; delay, multiplier, jitter and maxDelay turn that into exponential backoff. Narrow it with includes, so a validation error is not retried three times.
It is proxy-based like the rest of this guide, and the order is fixed. Retry wraps the transaction, so each attempt runs in a fresh transaction after the previous one rolled back. It also wraps the cache, so every attempt checks the cache first.
@ConcurrencyLimit caps how many threads may run a method at once, which matters with virtual threads, where no pool size limits you. A circuit breaker, which stops calling a failing dependency for a while, is not part of the framework at all.
Rule: enable resilient methods, retry only failures that can heal, and remember that the caller still handles the last one.
What the running process will tell you
~2 min
Actuator endpoints are enabled but almost none are exposed: over HTTP you get health, and its details are hidden. Logging has the same shape — console only, no file — so both are decisions to make before production rather than defaults to inherit.
Actuator adds operational endpoints, and exposure is a separate question from enablement: by default only health is published over HTTP. You widen it by name with management.endpoints.web.exposure.include, alongside an exclude that wins over it. The misconception runs both ways: that adding actuator publishes everything, or that a green health check means something while show-details is still never and it is reporting one word about an application with a broken component inside it.
Availability is tracked separately from health: liveness turns correct once the context has refreshed and readiness once your runners finish, surfacing at /actuator/health/liveness and /actuator/health/readiness.
For logging, Boot uses commons logging internally and configures Logback through the starters. The console carries error, warning and info by default, logging.level.<name> changes any logger, and nothing is written to a file at all unless you set logging.file.name or logging.file.path. Where a collector is reading the output, logging.structured.format.console emits JSON in one of the supported schemas instead of the human-readable pattern.
Rule: expose endpoints by name and turn health details on, or your health check is a liveness probe wearing a costume.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.