A 3-year Java engineer at a Pune fintech got asked one question in June 2026 that decided the whole loop: why does the team's style guide ban @Autowired on a field when every tutorial online does exactly that. She named the annotation correctly on the first pass. She couldn't say why it's discouraged, and the interviewer moved on without asking anything else. That's usually where Spring Boot interviews actually live, not in whether you've seen an annotation before, in whether you know what it costs you.
Spring Boot is used by 15.6 percent of professional developers according to the 2025 Stack Overflow Developer Survey, and Java itself sits at 29.6 percent, eighth among languages, well behind JavaScript and Python but still ahead of Kotlin, Go, and Rust combined. That's not a shrinking footprint. It's an enormous, mostly-invisible one, since almost none of that usage shows up in flashy greenfield demos. Here's an opinion that might be wrong: I think most spring boot interview questions still test whether you've memorized an annotation's name, when the actual filter in a 2026 loop is whether you can explain what breaks when two of those annotations interact badly, a circular dependency, a self-invoked @Transactional method, an autoconfigured bean you never asked for.
This page covers Spring Boot vs. the Spring Framework, autoconfiguration and starters, the IoC container and dependency injection, the annotations everyone lists but can't always explain, REST APIs, Spring Data JPA, exception handling, profiles and config, Actuator, security, caching, microservices with Spring Cloud, and testing. It's organized by topic, not company, since the same bean-scope question shows up almost verbatim whether you're interviewing at a 200-person product company or a Bangalore IT-services shop staffing a banking client.
Spring vs. Spring Boot: what actually changed
Almost every loop opens here, and a shaky answer sets a bad tone for everything after it, even at the senior level.
Easy questions
19The Spring Framework is the underlying platform: the IoC container, dependency injection, AOP, and modules like Spring MVC and Spring Data that you wire together yourself, usually with a fair amount of XML or Java config. Spring Boot is built on top of that same framework. It doesn't replace anything underneath, it adds autoconfiguration, starter dependencies, an embedded server (Tomcat by default), and a set of sane defaults so you can run a working application from a single main method instead of assembling a web.xml and a dispatcher servlet by hand.
Every Spring Boot bean you use is still a plain Spring bean, managed by the same IoC container. Boot's job is deciding what to wire for you automatically, not changing how the container itself works.
It undercuts it. Less XML is a side effect, not the point. The real shift is autoconfiguration deciding, at startup, which beans your classpath actually needs, and an embedded server meaning you ship a runnable JAR instead of a WAR dropped into a separately-managed Tomcat instance.
Plenty of plain-Spring shops had already killed most of their XML with Java-based @Configuration classes years before Boot existed. What Boot actually changed was removing the "assemble it yourself" step entirely for the 80 percent case, and giving you an escape hatch for the other 20.
It's a meta-annotation stacking three separate ones. @SpringBootConfiguration is a specialized @Configuration, so the class itself can define @Bean methods. @EnableAutoConfiguration triggers the whole conditional-bean process covered below. @ComponentScan scans the current package and everything underneath it for @Component, @Service, @Repository, and @Controller classes.
Drop your main class into a package that's a sibling of your feature packages rather than their parent, and @ComponentScan quietly stops finding half your beans, since it only scans downward from wherever the annotated class actually lives. That's a real gotcha on large multi-module projects, not just an edge case someone made up for an interview.
Both let you run code once, right after the application context finishes loading and before Boot considers the app started. Implement either interface as a bean and Spring calls its single method automatically on startup, no manual wiring required. The only real difference is the method signature: CommandLineRunner.run() takes a raw String[] of command-line arguments, ApplicationRunner.run() takes an ApplicationArguments object that already parses --key=value style options into named and non-named groups for you.
I default to ApplicationRunner unless I'm specifically parsing positional arguments, since the parsed accessor methods save you from splitting strings by hand. Multiple runner beans in the same app execute in the order @Order specifies, or declaration order if nobody bothered to annotate them.
A starter is a curated dependency descriptor, a Maven or Gradle artifact that pulls in a whole cluster of libraries known to work well together, instead of you hand-picking every JAR and hoping the versions don't clash.
spring-boot-starter-web pulls in Spring MVC, Jackson for JSON, an embedded Tomcat, and Bean Validation. spring-boot-starter-data-jpa pulls in Hibernate, Spring Data JPA, Spring ORM, and HikariCP (the default connection pool since Boot 2.0). The starter itself has almost no code in it, its real job is a dependency-management pom that pins compatible versions so you don't end up with a Hibernate build that silently breaks against a newer Spring release.
Constructor injection. Dependencies become final and immutable, the class is trivially testable by just calling the constructor with mocks (no Spring context required at all), and a missing dependency fails loudly at application startup instead of surfacing as a NullPointerException three requests into production.
Field injection, the @Autowired-on-a-field pattern almost every beginner tutorial teaches first, hides the dependency list from anyone reading the class and makes it impossible to construct the object without reflection or a container. IntelliJ's own built-in inspection flags it the moment you type it: "Field injection is not recommended." Since Spring 4.3, if a class has exactly one constructor, @Autowired on it is optional. Spring wires it implicitly either way.
@RequestParam pulls a value off the query string or a form field, /users?status=active. @PathVariable pulls a value out of the URI template itself, /users/{id}. @RequestBody deserializes the entire HTTP request body, typically JSON, into a Java object using a registered HttpMessageConverter, Jackson by default.
Mixing these up is a common fresher mistake: putting @RequestBody on a GET endpoint, for instance, where there usually isn't a meaningful body to deserialize in the first place.
That's a CORS rejection, the browser blocking a cross-origin request before it even reaches your controller, not a server-side error you can catch. Configure it in one place instead of scattering @CrossOrigin annotations across every controller.
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowCredentials(true);
}
}allowedOrigins("*") with allowCredentials(true) together will actually throw a startup exception in recent Spring versions, browsers refuse to send credentials to a wildcard origin, so Spring stops you from shipping a config that wouldn't have worked anyway.
MultipartFile as a @RequestParam, backed by Spring's multipart resolver, which Boot autoconfigures the moment spring-boot-starter-web is on the classpath. The file arrives as bytes you can inspect, validate, or stream to disk or object storage yourself.
@PostMapping("/upload")
public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return ResponseEntity.badRequest().body("File is empty");
}
// save file.getBytes() to disk, S3, wherever
return ResponseEntity.ok(file.getOriginalFilename());
}spring.servlet.multipart.max-file-size defaults to 1MB, which surprises almost everyone the first time a real-sized file gets rejected with a MaxUploadSizeExceededException. Bump it in application.yml before anyone tests with an actual photo instead of a two-line text file.
Spring Data parses the method name against a fixed keyword grammar, findBy, And, Or, OrderBy, comparison keywords like GreaterThan or Containing, and maps each recognized property name (Email, Status) against your entity's actual fields. findByEmailAndStatus(String email, String status) becomes something close to WHERE email = ?1 AND status = ?2 under the hood, generated once at startup, not re-parsed on every call.
The convention is genuinely fragile past a certain complexity. Rename a field on your entity and forget to update the method name, and you get a startup-time PropertyReferenceException instead of a silent bug, which is at least the better failure mode.
@ControllerAdvice is a class-level annotation that scopes @ExceptionHandler methods across every controller in the application (or a subset, if you constrain it with basePackages or assignableTypes), instead of repeating the same try/catch logic in every single controller method.
Throw a UserNotFoundException anywhere in any controller, and Spring's exception-resolution machinery walks up the call stack, finds the matching @ExceptionHandler method in your @ControllerAdvice class, and routes the exception there automatically. Your controller methods stay focused on the happy path.
It bundles @ControllerAdvice with @ResponseBody, the same relationship @RestController has to @Controller. Every @ExceptionHandler method inside gets its return value serialized straight to the response body as JSON, instead of being resolved as a view name the way a plain @ControllerAdvice would try to do by default.
For a JSON API, @RestControllerAdvice is close to the only version worth reaching for. Plain @ControllerAdvice makes sense mainly in an app that's also rendering server-side views and needs some handlers to return a template name.
A profile is a named set of beans and properties that only load when that profile is active. application-prod.yml overlays (not replaces) the base application.yml when the prod profile is active, set through spring.profiles.active=prod as an environment variable, a JVM flag, or a command-line argument. Beans can be scoped to a profile too, with @Profile("prod") on a @Configuration class or @Bean method, so a mock payment gateway bean only registers in dev or test, never in prod.
Functionally, no, both bind to the same underlying property source and Boot reads either one identically. YAML reads better once you have nested config, database, mail, and security sections each with several sub-keys, since indentation replaces repeating the same dotted prefix on every line. Properties files are flatter and occasionally easier to grep in a hurry.
The one real gotcha, YAML is whitespace-sensitive and doesn't forgive a stray tab character the way most editors default to, mixing tabs and spaces in a YAML file produces a parse error that looks nothing like the actual mistake. I've lost more time to that than to any other config-file issue on this list.
Every endpoint locks down behind HTTP Basic auth immediately. Boot generates a random UUID password at startup, logs it to the console ("Using generated security password: ..."), against a default in-memory user named user. No config file needed, adding the dependency alone triggers it.
Nobody ships that default to production. It exists so a bare app isn't wide open the instant you add the starter, forcing real security config before deployment.
MockMvc, usually paired with @WebMvcTest. It simulates an HTTP request against your controller with no real network port and no server starting, and lets you assert on status, headers, and JSON body in one fluent call.
The service layer gets mocked with @MockBean instead of wired for real. You're testing that your controller maps requests and responses correctly, not re-testing logic that already has its own unit tests elsewhere.
Storing a plaintext password means a single database leak hands over every user's real password, and since people reuse passwords across sites, that leak compounds well past your own app. BCrypt hashes the password with a built-in random salt baked into the output string itself, so two users with the identical password get completely different stored hashes, and it's deliberately slow, tunable through a work-factor parameter, specifically to make brute-forcing millions of guesses impractical even with modern hardware.
PasswordEncoder.matches(rawPassword, storedHash) is how you verify a login attempt, you never decrypt the stored hash back into a password, because BCrypt is one-way by design. There's no decrypt method to call even if you wanted one.
@EnableCaching on a configuration class turns caching on, then @Cacheable on any method tells Spring to check a cache before running the method body at all, and store the result under a generated key afterward.
@Cacheable(value = "products", key = "#id")
public Product findProduct(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}Boot defaults to a simple ConcurrentHashMap-based cache if nothing else is configured, fine for a demo, not for anything you'd actually run in production since it's per-instance and vanishes on restart. A real deployment reaches for Redis or Caffeine, wired in as the CacheManager bean, so the cache survives restarts (Redis) or at least gets proper eviction policies (Caffeine) instead of growing unbounded in memory.
A Feign client, an interface where you declare the HTTP calls you want to make and Spring Cloud OpenFeign generates the implementation at runtime, wiring in Eureka-based load balancing automatically if it's on the classpath.
@FeignClient(name = "inventory-service")
public interface InventoryClient {
@GetMapping("/api/inventory/{sku}")
InventoryStatus checkStock(@PathVariable String sku);
}Calling inventoryClient.checkStock("SKU-123") from another service reads exactly like a local method call, no manual URL building, no manual JSON parsing, the load balancer picks a healthy instance from Eureka's registry behind the scenes. RestTemplate still works fine for a one-off call, Feign earns its keep once you're calling the same downstream service from more than one place in the codebase.
Medium questions
21The starter puts HikariCP, an embedded H2 driver (if it's on the classpath), and Spring's JPA autoconfiguration classes onto the classpath. DataSourceAutoConfiguration, one of those classes, is gated by @ConditionalOnClass(DataSource.class) and @ConditionalOnMissingBean. Both conditions pass, since you added the starter and haven't declared your own DataSource bean, so Boot wires one automatically, pointed at whatever connection properties you've set in application.yml, or an in-memory H2 instance if you haven't set any at all.
The moment you declare your own @Bean DataSource, that same conditional fails and Boot's version quietly steps aside. This is the exact mechanism behind almost every "how does X get configured automatically" question you'll get asked about Boot.
Start the app with the --debug flag and read the conditions report Boot prints: a positive-matches list (what fired, and why) and a negative-matches list (what got rejected, and by which condition). Once Actuator's on the classpath, /actuator/conditions gives you the same report over HTTP instead of scrolling logs, which is the version I'd reach for once the app is running anywhere other than my own laptop. There's no magic in either report, just a long list of conditions somebody already evaluated for you.
Singleton is the default and covers the overwhelming majority of beans: one shared instance per container. Prototype creates a brand-new instance every time the bean is requested, useful for stateful, non-thread-safe objects you don't want shared across requests. The remaining four (request, session, application, websocket) only exist in a web-aware context, scoped to the lifetime of an HTTP request, an HTTP session, the ServletContext, or a WebSocket session respectively.
I'd say prototype scope is rarer in real Spring Boot codebases than interview prep material suggests. Most "I need a fresh instance per use" problems get solved with a factory method or an ObjectFactory<T> injected into a singleton, rather than the bean itself being prototype-scoped.
Three real options. Mark one implementation @Primary and Spring injects it by default whenever the interface is requested without further hints, only useful when one implementation is genuinely the "normal" choice. Tag each implementation with a distinct name in @Qualifier and reference that same name at the injection point, which works even when neither implementation is clearly the default. Or rename the constructor parameter to match one bean's name exactly, since Spring falls back to matching by variable name before giving up, though I wouldn't rely on that one for anything a teammate has to maintain later.
@Primary and @Qualifier on the same interface together is a common bug. @Qualifier at the injection point always wins over @Primary, so a leftover @Primary annotation on the wrong implementation can sit there silently doing nothing while everyone assumes it's still in charge.
They're all meta-annotated with @Component, so Spring's component scan registers them identically as far as bean creation goes. The difference is what rides along with each one. @Repository adds automatic exception translation, Spring wraps a raw SQLException or similar persistence-specific exception into its own unchecked DataAccessException hierarchy, so a service calling a repository never needs to know which database driver actually threw what.
@Service and @Controller carry no extra framework behavior by default, they're semantic markers, mainly useful for readability and for AOP pointcuts (transaction advice is commonly configured to target @Service-annotated classes by convention, not because the framework enforces it). @RestController is @Controller plus @ResponseBody bundled together, added in Spring 4.0 specifically so REST APIs didn't need to annotate every single handler method.
Aspect-Oriented Programming lets you attach behavior to method calls without touching the method itself, logging, timing, auditing, retried calls, cross-cutting concerns that would otherwise get copy-pasted into every service method. @Aspect marks a class as holding that behavior, and a pointcut expression, execution(* com.example.service.*.*(..)), tells Spring which methods to intercept. @Before runs code ahead of the matched method, @After runs regardless of outcome, and @Around wraps the whole call, letting you skip the method entirely, change its arguments, or swap its return value.
Spring AOP is proxy-based, the same mechanism behind @Transactional, so it inherits the identical self-invocation limitation. Call an @Around-advised method from another method in the same class and the aspect never fires, for the same reason a self-invoked @Transactional method never opens a transaction. If you've already explained the @Transactional self-invocation trap earlier in the interview, saying this one out loud usually lands well.
Control. Return a plain object from a @RestController method and Spring defaults to a 200 OK, with no way to signal "this was actually created" (201) or "this wasn't found" (404) from the return type alone, short of throwing an exception or slapping @ResponseStatus on the method.
ResponseEntity lets you set the status code, custom headers, and the body all from the same return statement, ResponseEntity.status(HttpStatus.CREATED).body(savedUser). For simple GET endpoints that always succeed with the same status, a plain object is fine and arguably reads cleaner. For anything conditional, use ResponseEntity.
It wraps the method in a database transaction, commit on normal return, rollback on an unchecked exception (checked exceptions don't roll back by default, which surprises people). Mechanically it's proxy-based AOP, Spring wraps your bean in a proxy at startup, and that proxy is what actually opens and closes the transaction.
The mistake: calling a @Transactional method from another method in the same class. That's a plain internal call, this.someMethod(), never routed through the proxy, so none of the transactional advice fires. The method still runs, it just quietly isn't transactional anymore, and nothing throws an error to say so. Restructure so the call comes from a different bean, or, less cleanly, inject a self-reference through ApplicationContext.
Extend PagingAndSortingRepository (or just use JpaRepository, which already includes it) and accept a Pageable parameter instead of returning a full List. Spring Data injects page number, size, and sort order straight from query parameters like ?page=0&size=20&sort=createdAt,desc, no manual parsing required.
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByStatus(String status, Pageable pageable);
}The returned Page object carries the content plus totalElements, totalPages, and whether there's a next page, everything a frontend needs to render pagination controls without a second count query written by hand. Watch the count query itself on a large table though, Page runs a separate SELECT COUNT(*) for every request unless you switch to Slice, which skips the count and only tells you whether more results exist.
@ResponseStatus(HttpStatus.NOT_FOUND) directly on a custom exception class is simpler for exceptions that always map to exactly one status code and never need a custom response body, Spring reads the annotation and sets the status automatically the moment the exception propagates uncaught. No @ControllerAdvice class involved at all.
The moment you need a structured error body, a field-level validation map, a trace ID, anything beyond a bare status code, that annotation stops being enough and you're back to an @ExceptionHandler. Most production APIs end up using @ExceptionHandler for nearly everything within a few months, since "just a status code, no body" rarely survives contact with a real frontend team asking for error details to display.
Command-line arguments beat almost everything. OS environment variables come next in most real deployments, which is why containerized apps set config through env vars instead of baking values into the JAR. A profile-specific file beats the base application.yml when that profile is active, and a hardcoded @Value("${some.prop:default}") fallback only applies once nothing else in the chain set the property. Most config incidents I've seen aren't "Spring picked the wrong value," they're a team forgetting an env var set in the deploy pipeline had already overridden the file they were staring at.
Actuator is a starter that exposes production-readiness endpoints over HTTP and JMX: health, metrics, environment properties, thread dumps, and more. Only /actuator/health and /actuator/info are exposed by default, everything else needs an explicit management.endpoints.web.exposure.include setting.
/actuator/health first, for a fast pass-fail signal, then /actuator/metrics for anything specific: JVM memory, request latency, pool usage. One underused endpoint: /actuator/loggers flips a package's log level from INFO to DEBUG at runtime via a POST request, no restart, no redeploy, no waiting for the next deploy window while something's actively on fire.
@Value pulls one property per annotation, scattered across however many classes need it, with no compile-time check that the property even exists until the app actually starts and that specific line runs. @ConfigurationProperties binds an entire prefixed block of config to a single strongly-typed class in one place, nested objects, lists, and Maps included, and Spring Boot's own annotation processor can generate IDE autocomplete metadata for it.
@ConfigurationProperties(prefix = "app.mail")
public class MailProperties {
private String host;
private int port;
private boolean sslEnabled;
// getters and setters
}Add @Validated alongside @ConfigurationProperties and Bean Validation annotations on the fields (@NotBlank, @Min) actually get enforced at startup, so a missing required property fails the app immediately with a clear message instead of surfacing as a null three services downstream.
Implement HealthIndicator (or extend AbstractHealthIndicator) as a bean, and Boot's health aggregator automatically includes it in the /actuator/health response alongside the built-in database and disk-space checks.
@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {
private final PaymentGatewayClient client;
public PaymentGatewayHealthIndicator(PaymentGatewayClient client) {
this.client = client;
}
@Override
public Health health() {
try {
client.ping();
return Health.up().build();
} catch (Exception e) {
return Health.down(e).withDetail("gateway", "unreachable").build();
}
}
}One down indicator flips the whole aggregate status to DOWN by default, which is exactly what you want feeding a load balancer or Kubernetes readiness probe, a pod that can't reach a critical dependency shouldn't keep receiving traffic just because its own JVM is technically alive.
Disable CSRF (it guards against a browser-session attack that doesn't apply to a token-authenticated API with no cookies), set session creation to stateless so Spring never touches an HttpSession, and permit public endpoints explicitly before requiring authentication on everything else.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}The JWT filter itself is the part interviewers usually gloss over verbally but expect you to have actually built once: pulling the token off the Authorization header, validating it, and populating the SecurityContext before the request reaches your controller.
@SpringBootTest boots the entire application context, real beans, real wiring, the closest thing to a genuine integration test, and the slowest of the three. @WebMvcTest loads only the web layer, controllers, converters, MockMvc, with service beans mocked through @MockBean, so you're testing routing and serialization, not business logic.
@DataJpaTest loads only JPA config against an embedded test database, rolling back each test's transaction afterward so nothing leaks into the next one. Reach for @WebMvcTest or @DataJpaTest whenever you can, they're faster and narrower. Save @SpringBootTest for the handful of tests that genuinely need the whole wiring.
@PreAuthorize with a SpEL expression, evaluated before the method runs. Enable it with @EnableMethodSecurity on a configuration class, then annotate any bean method Spring manages.
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}It works because Spring Security wraps the bean in a proxy, the same AOP mechanism behind @Transactional, so it has the identical self-invocation blind spot: call this method from another method in the same class and the check never fires. That pattern keeps showing up across the framework once you notice it once.
@Mock is plain Mockito, it creates a mock object with no Spring context involved at all, meant for fast unit tests where you construct the class under test directly and hand it mocked dependencies yourself. @MockBean is Spring Boot's own annotation, it replaces a real bean inside the Spring application context with a Mockito mock, for tests like @WebMvcTest or @SpringBootTest that actually boot some slice of the container.
Reach for @Mock whenever you're not loading a Spring context at all, it's faster since there's no context to start. @MockBean only earns its cost in tests that need the container anyway, using it just to avoid writing a plain constructor call is paying for a slower test for no real benefit.
Nothing evicted the stale entry. @Cacheable only ever populates a cache, it never invalidates one, so an update method needs its own @CacheEvict (or @CachePut, if you want to refresh the entry rather than just clear it) pointed at the same cache name and key.
@CacheEvict(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
return productRepository.save(product);
}The key expression on the evict has to actually resolve to the same value the original @Cacheable used, key = "#id" on one method and key = "#product.id" on another look similar but generate different cache keys if the parameter shapes don't line up, and Spring won't warn you, it just quietly evicts nothing and the stale value sits there until it expires on its own.
Service discovery, and Netflix Eureka (through Spring Cloud Netflix) is the version most Spring Boot shops still reach for even with Netflix itself long past actively developing it. Each service registers itself with a Eureka server on startup, its own instance ID, host, and port, and periodically sends a heartbeat to prove it's still alive. Any service that needs to call another one asks Eureka for the current list of healthy instances instead of reading a URL out of a config file.
@EnableDiscoveryClient on a service's main class is what registers it. The real payoff shows up during a rolling deploy, old instances heartbeat out and get removed from the registry, new instances register themselves, and callers never touch a hardcoded IP that changed underneath them mid-deploy.
Start with the properties that actually matter: maximum-pool-size (default 10), minimum-idle, connection-timeout (30 seconds by default, how long a thread waits to borrow a connection before it gives up), idle-timeout, and max-lifetime. Max-lifetime should be set a few seconds shorter than whatever timeout your database or the network in front of it enforces, otherwise you get connections that look healthy in the pool but get killed mid-query by a firewall or the DB's own wait_timeout, and you see intermittent "connection reset" errors that are hard to reproduce. Leak-detection-threshold is worth turning on in staging too, it logs a stack trace for any connection held longer than the threshold, which is how you actually find the code path that opens a connection and forgets to close it.
Setting maximum-pool-size too high is the more common mistake than setting it too low, and it doesn't help the way people expect. HikariCP's own guidance is roughly (core_count * 2) + effective_spindle_count as a starting point, not a number in the hundreds. If you run five app instances each with a pool of 50 against a Postgres box with max_connections set to 100, you'll blow through the database's connection limit before you even hit real traffic, and you'll get "FATAL: sorry, too many clients already" errors that have nothing to do with your app code. Even below that hard limit, more connections than the database has cores to serve them just means more context switching and lock contention on the DB side, so throughput actually drops as you raise the pool size past a certain point.
The opposite failure, pool too small, shows up as threads blocking on HikariPool.getConnection() and eventually throwing SQLTransientConnectionException with "Connection is not available, request timed out." A thread dump during an incident will show a pile of threads parked in the same place, which is the tell that you're pool-starved rather than database-starved, and the fix there is either raising minimum-idle/maximum-pool-size a bit or finding the query that's holding connections too long instead of just cranking the numbers up.
Hard questions
12Spring Boot's own reference docs describe it plainly: autoconfiguration "attempts to automatically configure your Spring application based on the jar dependencies that you have added" (Spring Boot reference docs, Auto-configuration). @EnableAutoConfiguration, pulled in automatically through @SpringBootApplication, tells Boot to read a list of candidate autoconfiguration classes. As of Spring Boot 2.7, that list lives in a flat file at META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, one fully qualified class name per line, replacing the older spring.factories registration.
Each candidate class is gated behind conditional annotations, most often @ConditionalOnClass (only apply if a specific class is on the classpath) and @ConditionalOnMissingBean (only apply if you haven't already defined your own bean of that type). Boot evaluates every condition and only registers the beans that pass. It's non-invasive by design: define your own DataSource bean, and the auto-configured one silently backs off instead of fighting you for the slot.
Build a small library module with a plain @Configuration class holding the shared producer's @Bean methods, gated behind @ConditionalOnClass(KafkaTemplate.class) so it only activates when Kafka is actually on the consuming project's classpath, and @ConditionalOnMissingBean so any service that wants to override the default still can. Register that class's fully qualified name in a META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports file inside the library jar, the same mechanism Boot's own starters use.
Ship it as two artifacts if you're doing this properly, an -autoconfigure module with the actual @Configuration classes and a thin -starter module that just depends on it plus whatever client libraries it needs, mirroring how spring-boot-starter-data-jpa is really a dependency wrapper around spring-boot-autoconfigure's JPA classes. Most teams skip the two-module split for internal-only libraries and just ship one jar. Nobody outside your org will ever notice.
The container instantiates the bean, populates its dependencies, then fires any Aware callbacks it asked for (BeanNameAware, ApplicationContextAware, and similar), giving it access to container internals. BeanPostProcessor.postProcessBeforeInitialization runs next, then the actual init step, @PostConstruct or InitializingBean.afterPropertiesSet(), then postProcessAfterInitialization, and the bean is ready.
On shutdown, @PreDestroy or DisposableBean.destroy() fires, but only for singletons, the container manages their full lifecycle. A prototype bean gets handed off after creation and Spring stops tracking it entirely, so its destroy callback never runs on its own. That's the detail candidates most often miss.
It throws BeanCurrentlyInCreationException at startup. Spring resolves circular dependencies between singletons using a three-level cache of early object references, but that trick only works for setter or field injection, where a partially-built reference can be handed out and filled in later. Constructor injection needs the full object graph before the constructor runs at all, so there's no partial reference available.
Three real fixes: extract the shared responsibility into a third bean both depend on, switch one side to setter injection so the early-reference trick applies, or add @Lazy to one constructor parameter so Spring injects a proxy that resolves on first use. I'd reach for the refactor first. Most circular dependencies are design smells wearing a wiring-error costume, and @Lazy just delays the conversation the team needs to have anyway.
Use a stereotype annotation (@Component, @Service) when Spring itself can construct the object from its own constructor, your own class, wired by the container directly. Reach for a @Configuration class with @Bean methods when you're wiring something you don't own the source of, a third-party client, a library's builder object, anything that needs custom construction logic Spring can't infer from annotations alone.
Here's the part most candidates miss: @Configuration classes get CGLIB-proxied by default ("full" mode). Call one @Bean method from inside another @Bean method in the same class, and the proxy intercepts that call and returns the already-registered singleton instead of running the method body again. Drop @Configuration down to a plain @Component with @Bean methods (Spring's "lite" mode) and that guarantee disappears entirely, each call becomes an ordinary Java method invocation and hands back a fresh object every time. That distinction has bitten more than a few teams who assumed @Bean always meant singleton, regardless of where it was declared.
Constructor injection for the service dependency, @PathVariable for the single-resource GET, @RequestBody for the POST payload, and ResponseEntity on the POST so the 201 status is explicit rather than assumed.
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<User> createUser(@RequestBody @Valid UserRequest request) {
User saved = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
}The @Valid annotation on the POST parameter is worth calling out unprompted, it triggers Bean Validation against annotations on UserRequest (like @NotBlank or @Email) and returns a 400 automatically if they fail, before your service method ever runs.
At startup, Spring Data's RepositoryFactoryBean creates a JDK dynamic proxy for your interface, there's no generated source file you can go read. Calls to the standard CRUD methods, save(), findById(), delete(), get routed to SimpleJpaRepository, the one concrete class backing nearly every Spring Data JPA repository regardless of what entity it's parameterized over.
Calls that don't exist on SimpleJpaRepository, your own custom method names, get handled differently. The proxy's method interceptor parses the method name at proxy-creation time and builds the corresponding JPQL query, before you ever call it at runtime.
You fetch N parent entities with one query, and each has a lazily-loaded @OneToMany collection. The moment your code, or your JSON serializer, touches that collection on each parent, Hibernate fires a separate query per parent. One query becomes N+1, and it's easy to miss with a handful of test rows in dev, then show up as real latency once the table has a few thousand real ones.
// N+1 trap: each order.getItems() call fires its own query
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
order.getItems().size(); // lazy load fires here, once per order
}
// fixed with a JOIN FETCH, one query total
@Query("SELECT o FROM Order o JOIN FETCH o.items")
List<Order> findAllWithItems();@EntityGraph is the other common fix if you'd rather not hand-write JPQL for every case. Either way, load the association eagerly in that one query, don't switch the mapping to eager globally, that just shifts the same N+1 cost onto every other query against the entity.
Add a @Version field to the entity, a Long or int Hibernate manages for you. Every update includes that version in its WHERE clause, and increments it on success. If User B's save fires with a stale version number because User A already committed a change in between, the WHERE clause matches zero rows, and Hibernate throws OptimisticLockException instead of silently overwriting data neither user saw the other change.
@Entity
public class Order {
@Id
private Long id;
@Version
private Long version;
// other fields
}This is optimistic locking, no database lock held while the user has the record open, which is the only version that scales to a real web app with people leaving tabs open for an hour. Pessimistic locking, an actual row-level SELECT ... FOR UPDATE, exists too, but I'd only reach for it on genuinely short-lived, high-contention operations like a seat reservation, not general CRUD editing.
Joins the same one, by default. REQUIRED, Spring's default propagation, means "use the current transaction if one exists, start a new one if it doesn't." Both methods commit or roll back together as a single unit.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAuditEvent(String message) {
auditRepository.save(new AuditEntry(message));
}REQUIRES_NEW suspends whatever transaction is already running and opens a completely separate one, its own commit, its own rollback, independent of the caller. That's the actual use case for the audit-log example above: you want the audit entry to survive even if the outer business transaction rolls back afterward. Get REQUIRED and REQUIRES_NEW confused and you'll either lose audit records you needed, or accidentally commit a partial write the outer transaction meant to undo.
Spring picks the handler mapped to the closest matching type in the exception's own class hierarchy, not the order the methods are declared in the file. A handler for the exact thrown class wins over one written for a superclass.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(UserNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Something went wrong"));
}
}Throw a UserNotFoundException against the class above and the specific handler runs, not the generic one, even though Exception.class technically matches too. Candidates who only ever write one catch-all handler rarely get asked this follow-up, since there's nothing to disambiguate between. Write two, and it comes up almost every time.
For a void-returning @Async method, the calling thread has already moved on before the async method even starts executing on the task executor's thread, so there's no call stack left for the exception to propagate back into. Spring's proxy catches it on the worker thread and, unless you've told it otherwise, just drops it. No stack trace in your logs, no 500 to the client, nothing. This is one of the more common surprises people hit the first time they slap @Async on something that used to be synchronous and expect it to fail the same way.
The fix for void methods is implementing AsyncConfigurer and overriding getAsyncUncaughtExceptionHandler to return a handler that actually logs the method, its arguments, and the throwable:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (throwable, method, params) ->
log.error("Async method {} failed with params {}", method.getName(), params, throwable);
}
}If the method returns CompletableFuture instead of void, the exception is captured on the future rather than swallowed, but it still only surfaces if the caller actually inspects it with .get(), .join(), or .exceptionally(). A lot of code calls an async method and ignores the returned future entirely, which puts you right back in silent-failure territory. Before chasing the exception handler, also check that the method isn't being called from within the same class (this.method()) instead of through the injected bean or a self-reference proxy, because Spring's @Async support is proxy-based and a same-class call bypasses the proxy entirely, meaning the method actually just runs synchronously and any exception behaves like a normal thrown exception, not an async one. That mismatch between what you expect to be async and what's actually running synchronously is usually the first thing to rule out.
How to prepare for a Spring Boot interview in 2026
Skip the annotation flashcards. Knowing that @Transactional "wraps a method in a transaction" doesn't help when an interviewer hands you a service class calling one of its own @Transactional methods and asks why the rollback isn't happening. Build one small app end to end instead: a REST controller, a Spring Data JPA repository, a @ControllerAdvice with at least two exception types, and one deliberately broken thing, a self-invoked transactional call, an N+1 query against a few thousand seeded rows, a circular constructor dependency you untangle by hand. Fixing your own mistakes teaches the mechanism faster than reading about it does.
Across Spring Boot-tagged mock interviews run through LastRoundAI, the stumble that shows up most isn't a missing annotation. It's candidates who name every bean scope correctly and then go quiet the moment a follow-up connects two of them, why a prototype bean injected into a singleton only gets created once, say, instead of fresh every time the way its name implies. I don't have a clean percentage for how often that specific follow-up catches people, only that it comes up enough in review to flag here.
Fresher and campus-hire loops (TCS, Infosys, Wipro, Cognizant) weight heavily toward annotations, REST APIs, and Spring Data JPA, plus one security question about the zero-config starter. Product-company and 3+ year loops lean harder into bean lifecycle, the self-invocation transaction trap, and N+1, since those separate someone who's used Spring Boot from someone who's actually debugged it under load.
Get the reps in before the real thing
Reading an answer to the self-invocation @Transactional question isn't the same as defending it live once an interviewer changes one detail on you mid-conversation. LastRoundAI's mock interview mode runs Java and Spring Boot rounds with real-time follow-ups instead of a static question bank, and the free plan includes 15 credits a month that reset monthly rather than stockpiling. Starter is $19/mo if you need more sessions than that covers.
If the slower part of your job search is finding enough Spring Boot and Java backend openings rather than passing the interview once you land one, Auto-Apply matches and applies to roles for you, 10 a month free, up to 400 a month on Ultimate, with every application held in a review queue until you approve it. Nothing goes out without you seeing it first.
Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

