This page is for Java developers facing a Spring Boot round, from a first backend job to a senior role. Most interviews start with what Boot adds to Spring, auto-configuration and starters, then move to beans, scopes and dependency injection, configuration and profiles, and how a request reaches a REST controller. The hardest part is usually Spring Data JPA: N+1 queries, lazy loading and why @Transactional sometimes does nothing. Expect Spring Security, Actuator and testing too, and in senior rounds a production story and a judgement call. Each question shows what the interviewer is checking and an answer you can say out loud.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Same core: Boot still uses Spring's container, MVC, Data and Security; it doesn't replace them.
Defaults: auto-configuration builds the usual beans from what's on the classpath and in your properties.
Packaging: starters manage compatible versions, and an embedded server lets you run one executable jar.
Production: externalised config, profiles and Actuator come built in.
"Spring Boot isn't a different framework, it's Spring with sensible defaults. With plain Spring I'd configure the DispatcherServlet, a DataSource, a transaction manager, Jackson and a server myself, and pick library versions that work together. Boot does most of that for me. Starters bring in a tested set of dependencies, auto-configuration looks at what's on the classpath and what I've set in my properties and creates the beans I'd normally write by hand, and the embedded server means I build one jar and run it with java -jar. On top of that I get externalised configuration, profiles and Actuator for health checks and metrics. The key point is that every default backs off as soon as I define my own bean, so I keep full control when I need it."
Describing Boot as a replacement for Spring, or saying it removes configuration rather than supplying defaults you can override.
Discovery: Boot reads a list of auto-configuration classes that each starter jar registers.
Conditions: each class is guarded by conditions such as @ConditionalOnClass, @ConditionalOnMissingBean and @ConditionalOnProperty.
Debugging: run with --debug, or use the Actuator conditions endpoint, to see the evaluation report.
Control: define your own bean, set a property, or exclude an auto-configuration class.
"At startup, @EnableAutoConfiguration loads a list of auto-configuration classes that the jars on the classpath register. Every one of those classes is guarded by conditions. For example, the DataSource configuration only applies when the JDBC classes are on the classpath, and it only creates a DataSource if I haven't defined one myself, which is @ConditionalOnMissingBean. Others check a property with @ConditionalOnProperty. So what I get depends on my dependencies, my properties and my own beans. When something surprises me, I start the app with --debug and Boot prints a conditions evaluation report: which auto-configurations matched and why the others didn't. With Actuator there's a conditions endpoint that shows the same thing. To switch one off completely, I use the exclude attribute on @SpringBootApplication or the spring.autoconfigure.exclude property."
@AutoConfiguration
@ConditionalOnClass(ObjectMapper.class)
public class AuditAutoConfiguration {
@Bean
@ConditionalOnMissingBean
AuditClient auditClient(ObjectMapper mapper) {
return new AuditClient(mapper); // skipped if the app defines its own AuditClient
}
}
Saying Boot scans the whole classpath and creates every bean it can, or having no way to find out why a bean exists.
Definition: a starter is a dependency descriptor that pulls in a working set of libraries, with little or no code of its own.
Web example: Spring MVC, Jackson for JSON, an embedded Tomcat, and the core starter with logging and auto-configuration.
Versions: the Boot parent or BOM fixes compatible versions, so you rarely write version numbers.
"A starter is mostly just a dependency descriptor. It has little or no code of its own; it pulls in a set of libraries that are known to work together. When I add spring-boot-starter-web, I get Spring MVC, Jackson for turning objects into JSON, an embedded Tomcat server, and the core starter, which brings logging and auto-configuration. Because those jars are now on the classpath, auto-configuration sets up the DispatcherServlet, the JSON converters and the server for me. The versions come from Boot's dependency management, through the parent pom or the BOM, so I don't list a version for each library and I don't end up with clashing ones. If I wanted Jetty instead of Tomcat, I'd exclude the Tomcat starter and add the Jetty one."
Thinking a starter holds the configuration code itself, or overriding managed versions casually without checking compatibility.
Three in one: @SpringBootConfiguration, @EnableAutoConfiguration and @ComponentScan.
Scan root: scanning starts from the main class's package and goes down into its sub-packages.
Symptom: a class in a sibling package is silently skipped, so you get a 404 or a missing bean error.
"@SpringBootApplication is a shortcut for three annotations. @SpringBootConfiguration marks the class as a configuration class, @EnableAutoConfiguration turns on Boot's auto-configuration, and @ComponentScan finds my components. The scan starts from the package the main class is in and walks down through its sub-packages. So if my main class is in com.shop.app and I put a controller in com.shop.web, it's outside the scan and Spring never registers it. There's no error about the controller itself; I just get a 404 for its endpoints, or an unsatisfied dependency error if another bean needs it. The clean fix is to put the main class in the root package of the project. I could set scanBasePackages instead, but moving the class is simpler and keeps things predictable."
Not knowing that scanning starts from the main class's package, or fixing it by scattering extra @ComponentScan annotations around.
IoC: the container creates objects and hands them their dependencies; the class doesn't build them.
Constructor wins: fields can be final, required dependencies are explicit, and missing ones fail at startup.
Testing: you can build the class with plain new and mocks, no Spring context needed.
Smell check: a constructor with many parameters tells you the class does too much.
"Inversion of control means my class doesn't create its own dependencies. The Spring container builds the objects, works out what each one needs and passes it in; dependency injection is how it passes them in. I prefer constructor injection for a few reasons. The fields can be final, so the object is fully built and can't be half-wired. Required dependencies are right there in the signature, so a missing bean fails at startup. And in a unit test I can just call new OrderService with a mock repository, without starting Spring. If a class has only one constructor, Spring uses it automatically, so I don't even need @Autowired. Field injection hides dependencies, needs reflection or a Spring context to test, and makes it too easy to keep adding more. When a constructor grows to seven or eight parameters, that's my signal to split the class."
@Service
public class OrderService {
private final OrderRepository orders;
private final Clock clock;
public OrderService(OrderRepository orders, Clock clock) { // no @Autowired needed
this.orders = orders;
this.clock = clock;
}
}
Saying field injection is fine because it's shorter, or not knowing that a single constructor needs no @Autowired.
Base: all are @Component, so component scanning picks up every one of them.
@Repository: gets persistence exception translation into Spring's DataAccessException family.
@Controller: is detected as a web handler; @RestController adds @ResponseBody.
@Service: adds no behaviour today; it documents the layer.
"They're all specialisations of @Component, so component scanning registers any of them as a bean. The difference is partly meaning and partly behaviour. @Repository marks a data access class, and Spring can wrap it so database-specific exceptions are translated into its own DataAccessException hierarchy. That way my service layer doesn't depend on JDBC or JPA exception types. @Controller tells Spring MVC the class holds request handlers, and @RestController is @Controller plus @ResponseBody, so return values are written straight into the response body. @Service doesn't add behaviour at the moment; it just says this is business logic. I still use it because it makes the layers obvious and gives aspects or tooling something to target. So they're not purely labels, but @Service mostly is."
Saying they are completely interchangeable with no difference at all, or not knowing that @RestController implies @ResponseBody.
Scopes: singleton by default, prototype, and web scopes such as request and session.
The trap: a singleton is wired once, so it keeps the one prototype instance it got at startup.
Fixes: ask for a fresh instance through ObjectProvider or a @Lookup method, or use a scoped proxy.
Threads: singleton doesn't mean thread-safe; shared mutable state is still your problem.
"The default scope is singleton, meaning one instance per application context. Prototype means a new instance every time the container is asked for the bean. In a web app there are also request and session scopes. The trap is mixing them. If a singleton service gets a prototype bean through its constructor, injection happens once, when the singleton is created, so the service holds that same instance forever. It looks like a prototype but behaves like a singleton. To get a fresh one each time, I inject an ObjectProvider of that type and call getObject when I need it, or use a @Lookup method. For request-scoped beans used inside singletons, a scoped proxy does the job: the singleton holds a proxy that finds the current request's instance on every call. And I remind people that singleton scope says nothing about thread safety."
@Service
public class ReportService {
private final ObjectProvider<ReportBuilder> builders;
public ReportService(ObjectProvider<ReportBuilder> builders) {
this.builders = builders;
}
public Report build(Order order) {
ReportBuilder builder = builders.getObject(); // new prototype on every call
return builder.from(order);
}
}
Believing Spring hands the singleton a new prototype on every method call, or treating singleton scope as a promise of thread safety.
Create and wire: instantiate, inject dependencies, then Aware callbacks.
Initialise: post-processors' before hooks, @PostConstruct, afterPropertiesSet, then the after hooks, where proxies are usually made.
Use: the bean serves calls while the context is running.
Destroy: on shutdown, @PreDestroy and destroy methods run for singletons.
"Spring first instantiates the bean, then injects its dependencies, then calls Aware interfaces like BeanNameAware if the class implements them. Next come the BeanPostProcessors. Their before-initialisation hook runs, then the init callbacks: @PostConstruct, then afterPropertiesSet if it implements InitializingBean, then any custom init method. Then the after-initialisation hook runs, and that's usually where Spring wraps the bean in a proxy for things like @Transactional or @Async. From there the bean is in use. On shutdown the context calls @PreDestroy, then destroy from DisposableBean, then any custom destroy method. That's for singletons; with prototypes Spring hands the object over and never destroys it. In practice I keep @PostConstruct light, and for work that needs the whole app ready I listen for ApplicationReadyEvent instead."
Putting heavy work or remote calls in @PostConstruct, or not knowing that proxies are applied after initialisation.
Why it fails: with constructor injection neither bean can be built first; newer Boot versions also refuse field and setter cycles by default.
Real fix: move the shared logic into a third bean, or reverse one direction with an event.
Stopgaps: @Lazy on one injection point, or allowing circular references, used knowingly and briefly.
"If A needs B in its constructor and B needs A in its constructor, there's no order in which Spring can build them, so startup fails. With field or setter injection Spring used to get around it by exposing a half-built bean early, but newer Boot versions turn that off by default. I think that's right, because a cycle usually means two classes know too much about each other. My first step is to look at what each one actually uses from the other. Often both depend on a small piece of logic that belongs in its own bean, so I pull it out and the cycle disappears. Sometimes one direction is really a notification, so publishing an application event decouples it. @Lazy on one constructor parameter works as a short-term patch because it injects a proxy, but I'd leave a ticket to clean it up."
Reaching straight for spring.main.allow-circular-references, or switching to field injection just to hide the cycle.
Order: command-line arguments beat environment variables, which beat values in application files.
Files: profile-specific files beat the default one, and files outside the jar beat those packaged inside.
Binding: relaxed binding maps SPRING_DATASOURCE_URL to spring.datasource.url.
Typed config: @ConfigurationProperties groups related settings into a validated class; @Value suits one-off values.
"Boot layers its property sources and the more specific one wins. A command-line argument beats an environment variable, and an environment variable beats whatever is in application.yml. Among the files, a profile-specific one like application-prod.yml overrides the plain one, and a file outside the jar overrides one packaged inside it. Relaxed binding means an environment variable called SPRING_DATASOURCE_URL sets spring.datasource.url, which is how most containers pass config in. For reading values, @Value is fine for a single setting. When I have a group, say a base URL, a timeout and a retry count for a payment client, I use @ConfigurationProperties with a prefix. I get one typed class, I can add @Validated so the app refuses to start with a bad value, and the IDE can autocomplete the keys. It's also far easier to test than strings spread through the code."
// register with @ConfigurationPropertiesScan on the main class
@Validated
@ConfigurationProperties(prefix = "payments.client")
public record PaymentClientProperties(
@NotBlank String baseUrl,
@NotNull Duration timeout,
@Min(0) int maxRetries) {
}
Not knowing that environment variables override the packaged file, or committing production secrets into application.yml.
Activate: set spring.profiles.active through an environment variable or argument, not in code.
Files: application.yml holds shared defaults; application-prod.yml holds only what differs.
Beans: @Profile switches whole beans, like a fake email sender for local runs.
Secrets: come from the environment or a secret store, never from profile files in git.
"A profile is a named set of configuration and beans that's only active when I switch it on. I keep shared defaults in application.yml and put only the differences in files like application-local.yml or application-prod.yml: the database URL, log levels, feature flags. The profile is chosen outside the code, usually with the SPRING_PROFILES_ACTIVE environment variable in the deployment, so the same jar runs everywhere. For behaviour differences I use @Profile on beans. Locally I might register a fake email sender under the local profile and the real one under prod. I'm careful about two things. Passwords never go in a profile file; they come from environment variables or a secret manager. And I avoid lots of tiny profiles that combine in confusing ways, because then nobody can say what production is actually running."
Using if-statements on an environment name inside business code, or storing production credentials in application-prod.yml.
Entry: the embedded server runs the servlet filters, including Spring Security's, then hands off to the DispatcherServlet.
Routing: handler mappings find the controller method; interceptors' preHandle runs.
Binding: argument resolvers fill path variables, query params and the body through Jackson.
Response: a message converter writes the return value; exceptions go to exception resolvers.
"The request lands on the embedded server, Tomcat by default, which runs it through the servlet filter chain. Spring Security lives there as a filter, so authentication happens before any controller code. Then it reaches the DispatcherServlet, the front controller for Spring MVC. It asks the handler mappings which method matches the path and HTTP method, runs any interceptors' preHandle, and calls the method through a handler adapter. Before the call, argument resolvers fill in the parameters: @PathVariable and @RequestParam from the URL, and @RequestBody by passing the body to a message converter, which uses Jackson for JSON, with validation if I've added @Valid. Because it's a @RestController, the return value is written to the response by a message converter chosen from the Accept header. If anything throws, the exception resolvers take over, and that's where my @ControllerAdvice handlers get called."
Having no idea the DispatcherServlet exists, or thinking security checks happen inside the controller by default.
Input: a request DTO with validation annotations, bound with @Valid @RequestBody.
Work: hand off to a service so the controller stays thin.
Output: 201 Created with a Location header and a response DTO, not the JPA entity.
Failure: invalid input becomes a 400 automatically, which you shape in a global handler.
"I'd start with a request record, CreateCustomerRequest, with @NotBlank on the name and @Email on the email. The controller method takes it with @Valid and @RequestBody, so if validation fails Spring throws MethodArgumentNotValidException before my code runs and the client gets a 400. The controller only calls the service, which saves the customer and returns a response DTO. I don't return the entity itself, because that ties my API to the database model and can trigger lazy loading while Jackson writes the JSON. Since this creates a resource, I return 201 Created with a Location header pointing at the new customer's URL, built from the current request. One thing to remember: validation needs spring-boot-starter-validation on the classpath. Without it, @Valid quietly does nothing."
public record CreateCustomerRequest(@NotBlank String name, @NotBlank @Email String email) {}
@RestController
@RequestMapping("/customers")
public class CustomerController {
private final CustomerService service;
public CustomerController(CustomerService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<CustomerResponse> create(@Valid @RequestBody CreateCustomerRequest req) {
CustomerResponse created = service.create(req);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}
}
Returning 200 with the JPA entity for a create, or validating by hand with if-statements inside the controller.
Central place: a @RestControllerAdvice class with @ExceptionHandler methods.
Mapping: not found to 404, conflicts to 409, validation to 400, everything else to 500.
Spring's own errors: extend ResponseEntityExceptionHandler so a catch-all doesn't turn a bad request into a 500.
Shape and safety: one format, such as ProblemDetail; full stack trace in the log, never in the response.
"I use a @RestControllerAdvice class, which applies to every controller, and give it @ExceptionHandler methods for each kind of failure. My own exceptions like CustomerNotFoundException map to 404, a duplicate email maps to 409, and a last handler for Exception returns a plain 500. I make the class extend ResponseEntityExceptionHandler, because otherwise that catch-all also grabs Spring's own exceptions, and a failed validation or broken JSON body comes back as a 500 instead of a 400. I override its validation hook to list the bad fields. For the body, recent Spring versions have ProblemDetail, which follows the standard problem details format for HTTP APIs, so clients always get the same structure: a status, a title and a detail. The client gets a safe, useful message, and the server log gets the full stack trace with a request id. A raw exception message or SQL error never reaches the response."
@RestControllerAdvice
public class ApiErrors extends ResponseEntityExceptionHandler { // keeps Spring's 4xx mappings
private static final Logger log = LoggerFactory.getLogger(ApiErrors.class);
@ExceptionHandler(CustomerNotFoundException.class)
ProblemDetail notFound(CustomerNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(Exception.class)
ProblemDetail unexpected(Exception ex) {
log.error("Unhandled error", ex);
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong");
}
}
Wrapping every controller method in its own try-catch, or returning stack traces and SQL messages in the response body.
Cause: one query loads N parents, then touching a lazy association fires one more query per parent.
Spot it: turn on SQL logging or Hibernate statistics in a test and count the queries.
Fix: a fetch join or @EntityGraph for that use case, batch fetching, or a DTO projection.
Careful: fetch-joining a collection while paging makes Hibernate page in memory.
"N+1 happens when I load a list of, say, 50 orders with one query, then loop over them and call getItems on each. Because items is lazy, Hibernate runs a separate query per order, so I get one query plus 50 more. It's fine in development with ten rows and falls over in production. I spot it by turning on SQL logging or Hibernate statistics in an integration test and checking the query count, or by seeing lots of identical queries in the database monitor. The fix depends on the use case. If this screen always needs the items, I add a repository method with a join fetch or an @EntityGraph so it's one query. If I only need a few columns, a DTO projection is even better. Batch fetching is a good general setting that turns N queries into a handful. I don't switch the mapping to eager, because then every other query pays for it."
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = "items")
List<Order> findByCustomerId(Long customerId);
@Query("select distinct o from Order o join fetch o.items where o.status = :status")
List<Order> findWithItemsByStatus(@Param("status") OrderStatus status);
}
Fixing it by making everything eager, or never having looked at the SQL Hibernate actually runs.
Cause: a lazy association is touched after the transaction and its persistence context have closed.
Open-in-view: Boot keeps the persistence context open for the whole web request by default, which hides the error.
The cost: lazy queries then run outside the transaction during rendering and can hold a connection longer.
Better fix: load what the response needs in the service and return DTOs; consider turning open-in-view off.
"Lazy associations are proxies that load when first touched, but they need an open persistence context to do it. If my service returns an entity, the transaction ends, and then Jackson touches a lazy collection while writing the JSON, there's no session left, so Hibernate throws LazyInitializationException. Boot enables open-in-view by default, which keeps the persistence context open until the web request finishes, and it logs a warning about that at startup. That makes the exception go away, but now lazy queries run outside any transaction while the response is being written. They're easy to miss, they can create N+1 problems, and the database connection can stay held for the rest of the request. I prefer turning open-in-view off, deciding in the service what each endpoint needs, fetching it with an entity graph or a query, and mapping to a DTO before the transaction ends."
Fixing it with FetchType.EAGER everywhere, or putting @Transactional on the controller without understanding the cost.
Proxy: the annotation only applies when the call comes in through the Spring proxy, so self-calls skip it.
Beans only: it applies to Spring-managed beans; objects made with new get nothing, and private methods are never intercepted.
Rollback rules: by default only unchecked exceptions and errors roll back; checked exceptions commit.
Swallowed errors: catching the exception inside the method means the proxy never sees it.
"@Transactional works through a proxy. Spring wraps my bean, and the proxy starts the transaction before calling my method and commits or rolls back afterwards. So the first thing I check is whether the call actually went through the proxy. If one method in a class calls another annotated method in the same class, that's a call on this, and no transaction logic runs. Same if the object was created with new rather than injected. Second, the default rollback rule: runtime exceptions and errors roll back, but checked exceptions commit unless I set rollbackFor. Third, if the method catches the exception and just logs it, the proxy sees a normal return and commits. Private methods are never intercepted either. With more than one data source, I'd check it's using the right transaction manager. For self-calls, the usual fix is moving the method into a separate bean."
@Service
public class InvoiceService {
public void importAll(List<Invoice> invoices) {
invoices.forEach(this::saveOne); // self-call: the proxy is bypassed, no transaction
}
@Transactional
public void saveOne(Invoice invoice) {
// ...
}
}
Assuming @Transactional works however the method is called, or that every exception causes a rollback.
REQUIRED: the default; join the current transaction or start one if there's none.
REQUIRES_NEW: suspend the current one and run in a separate transaction that commits on its own.
Use case: an audit or failure record that must be saved even if the main work rolls back.
Cost: it holds a second connection, which can exhaust the pool under load.
"REQUIRED is the default. If there's already a transaction, the method joins it; if not, it starts one. So when one service calls another, both usually share one transaction and commit or roll back together. One detail people miss: if the inner method throws a runtime exception and the outer method catches it, the shared transaction is already marked rollback-only, so the outer commit fails with UnexpectedRollbackException. REQUIRES_NEW suspends the outer transaction and runs the method in a brand new one that commits or rolls back by itself. I use it for things like an audit entry or a failed-payment record that must survive even when the main operation rolls back. The catch is that it needs a second database connection while the first is suspended. Under load with a small pool, every request can end up holding one connection and waiting for another, and everything stalls."
Thinking that catching an exception from an inner REQUIRED method lets the outer transaction commit normally.
Derived queries: the method name is parsed into a query when the app starts, so a typo fails fast.
Paging: a Pageable parameter adds sorting, limit and offset; a Page result also runs a count query.
Own query: use @Query or a projection when the name gets long or the query needs joins.
"Spring Data creates the implementation of my repository interface when the app starts. For findByEmailAndActiveTrue it parses the name: find by the email property, and active equals true, and builds a JPQL query from that. Because this happens at startup, a misspelt property name fails straight away rather than in production. If I add a Pageable parameter, it adds sorting, limit and offset, and if the return type is Page it also runs a count query to get the total. Slice skips that count, which is cheaper when I only need to know if there's a next page. Derived names are great for simple lookups, but once a name goes past three or four conditions, or I need joins, aggregates or a DTO projection, I write @Query with JPQL, or native SQL when I truly need database-specific features."
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmailAndActiveTrue(String email);
Page<User> findByRole(Role role, Pageable pageable);
@Query("select new com.shop.UserSummary(u.id, u.name) from User u where u.createdAt > :since")
List<UserSummary> recentSummaries(@Param("since") Instant since);
}
Writing enormous method names instead of a query, or not knowing that a Page result runs an extra count query.
Connection held: the transaction keeps a database connection busy while waiting on the network.
No real rollback: if the status update fails, the payment has already happened and won't be undone.
Better shape: short transactions around each step, an idempotency key and a pending state.
Reliable events: an outbox table if other systems need to hear about it.
"I'd raise two problems. First, the transaction holds a database connection for the whole payment call. If the provider is slow, every checkout holds a connection while it waits, and under load the pool runs dry and unrelated endpoints start failing. Second, the rollback is an illusion. If the payment succeeds and the status update then throws, the database rolls back the order, but the customer has been charged and we have no record. I'd suggest splitting it. One short transaction saves the order as pending. The payment call happens outside any transaction, with an idempotency key so a retry can't charge twice. A second short transaction marks it paid or failed. A scheduled job picks up anything stuck in pending and checks with the provider. If other services need to know, I'd write an outbox row in the same transaction as the status change. And I'd explain it with the failure example, not just quote a rule."
Saying @Transactional will roll back the payment too, or only suggesting a longer timeout.
Risk: update won't rename or drop safely, can't migrate data, and runs unreviewed at startup.
Alternative: versioned migrations with Flyway or Liquibase, kept in the repo and reviewed like code.
Setting: validate or none in production, so Hibernate never changes the schema itself.
Persuade: show a concrete failure and make the new path easy.
"I get why it's tempting, it feels free. But update only adds things. It won't rename a column, it can't move data from an old column into a new one, and it doesn't drop what's no longer used, so the schema slowly drifts. Worse, the change happens silently when the app starts, nobody reviews the SQL, and there's no record of what ran on which environment. I'd suggest Flyway or Liquibase instead: each change is a versioned script in the repo, reviewed like code and applied the same way everywhere. Boot runs them automatically at startup when the library is on the classpath. In production I'd set ddl-auto to validate or none, so Hibernate can check the mapping but never alters the schema. To win the argument, I'd walk through a real column rename and show where update breaks, then set up the first migration myself so the new way costs them nothing."
Agreeing because it works fine in development, or banning it without offering a workable migration process.
Default: adding the starter locks down every endpoint with a generated user and password.
Filters: a chain of security filters runs before the DispatcherServlet and fills the SecurityContext.
Config: declare a SecurityFilterChain bean; the old WebSecurityConfigurerAdapter is gone.
Rules: list specific matchers first and anyRequest last.
"Spring Security plugs in as a servlet filter, so it runs before the DispatcherServlet. Inside that one filter is a chain of security filters that read credentials, build the Authentication, store it in the SecurityContext and finally check whether the request is allowed. As soon as I add the starter, Boot secures every endpoint and logs a generated password for a default user, which is a safe default but not what I want. So I declare a SecurityFilterChain bean. With authorizeHttpRequests I say which paths are public, like the health check, which need a role, and then anyRequest().authenticated() last, because rules are checked in order. The older approach of extending WebSecurityConfigurerAdapter has been removed, so on a current project it's always the bean style. For stored passwords I keep only hashes, using a PasswordEncoder like BCrypt."
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Putting anyRequest() before the specific rules, or storing passwords in plain text or with a fast hash.
Resource server: use the OAuth2 resource server support to validate JWTs against the issuer's keys.
Stateless: set the session policy to stateless so no server session is created.
CSRF logic: the attack abuses credentials the browser sends automatically, like cookies.
Verdict: a token in the Authorization header isn't sent automatically, so CSRF can go; a token in a cookie keeps it.
"I'd use Spring Security's OAuth2 resource server support with JWT, point it at the identity provider's issuer, and let it fetch the public keys and check the signature, expiry and issuer on every request. I set session creation to stateless so no HTTP session is created, and map claims like scopes or roles to authorities. On CSRF, it depends on how the token travels. A CSRF attack works because the browser attaches cookies to a request that another site triggers. If my client sends the JWT in the Authorization header, the browser never adds it on its own, so a forged request arrives without credentials, and disabling CSRF is reasonable. But if the token lives in a cookie, the browser does send it automatically, and I'm back in CSRF territory, so I keep protection on. And I wouldn't hand-roll JWT parsing in a filter when the built-in support covers it."
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // only because tokens travel in the Authorization header
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));
return http.build();
}
Disabling CSRF out of habit without knowing what it protects against, or parsing JWTs by hand without verifying the signature.
Contain: cut exposure right away, at the gateway or through config.
Assess: check access logs for hits on env, configprops or heapdump, and treat exposed secrets as leaked.
Fix for good: expose only health and metrics, use a separate management port or auth, and add a pipeline check.
"I'd treat it as a security incident, not a tidy-up. The first step is to cut exposure, either by blocking /actuator at the load balancer or gateway, which is fastest, or by shipping a config change that limits exposure to health. Then I'd check the access logs for requests to env, configprops, heapdump or threaddump. A heap dump can hold passwords, tokens and customer data from memory, and although recent versions mask values in env by default, I wouldn't bet on that. If anything sensitive was hit, or I can't tell, I'd rotate the database password, API keys and signing keys. Then the lasting fix: a separate management port reachable only internally or behind authentication, an explicit list of exposed endpoints, and a pipeline check that fails the build if exposure is set to everything. And I'd tell the security team early, not after I've fixed it."
Quietly changing the config without checking what was accessed or rotating secrets that may have leaked.
Endpoints: health, info, metrics, loggers, env, beans, conditions and more.
Default: over HTTP only health is exposed; everything else is opt-in.
Probes: liveness and readiness health groups for orchestrators.
Metrics: Micrometer exports to a monitoring system such as Prometheus.
"Actuator adds operational endpoints under /actuator. Health tells a load balancer or orchestrator whether the app and things like its database are fine. Metrics, through Micrometer, gives request timings, JVM memory, connection pool usage and my own counters, and can export them to a monitoring system. Loggers lets me change a log level at runtime, and there are diagnostic ones like env, beans, conditions and heapdump. By default only health is exposed over HTTP. In production I'd expose health, info and the metrics or Prometheus endpoint, and I'd use the liveness and readiness groups so the platform only restarts the app when it's truly stuck, and stops sending traffic while it isn't ready. Anything sensitive, like env or heapdump, stays off or sits on a separate management port that only the internal network can reach, behind authentication."
Exposing every endpoint publicly, or putting a flaky downstream dependency into the liveness check so the app restarts in a loop.
Unit first: most logic is tested with plain JUnit and Mockito, no Spring at all.
Slices: @WebMvcTest loads the web layer with MockMvc; @DataJpaTest loads JPA and the repositories.
Full context: @SpringBootTest for a few end-to-end paths, ideally against a real database in a container.
Mocking: @MockitoBean replaces a bean in the context; older versions use @MockBean.
"I think in layers. Business logic gets plain unit tests with Mockito and no Spring, so they run in milliseconds. For a controller I use @WebMvcTest, which loads only the web layer: controllers, advice, converters and MVC config, but not services or repositories. I mock the service and use MockMvc to check status codes, JSON and validation errors. For repositories I use @DataJpaTest, which sets up JPA and rolls back each test's transaction. I prefer pointing it at a real database in a container rather than an in-memory one, because dialect differences hide bugs. @SpringBootTest loads everything, so I keep it for a handful of full-flow tests. To put a mock into the context, newer versions use @MockitoBean and older ones @MockBean. Each different set of mocks means a new context, so I keep them consistent to let Spring cache it."
@WebMvcTest(CustomerController.class)
class CustomerControllerTest {
@Autowired MockMvc mvc;
@MockitoBean CustomerService service; // @MockBean on older Boot versions
@Test
void returns404WhenCustomerIsMissing() throws Exception {
when(service.find(42L)).thenThrow(new CustomerNotFoundException(42L));
mvc.perform(get("/customers/42"))
.andExpect(status().isNotFound());
}
}
Using @SpringBootTest for every test, or relying only on an in-memory database that behaves differently from production.
Measure: Actuator metrics for busy threads, pending connections and slow calls before changing anything.
Pools: server worker threads and the HikariCP connection pool must fit each other and the database.
Blocking calls: outbound HTTP calls need timeouts, or one slow dependency ties up every thread.
Virtual threads: make waiting cheap, but don't add database connections.
"I'd measure first. Actuator with Micrometer shows Hikari's active and pending connections, the server's busy threads and timings per endpoint. A common story is that Tomcat has far more worker threads than the connection pool has connections, so under load most threads sit waiting for a connection. Making the pool huge isn't the answer, because the database has its own limits and gets slower with too many concurrent queries. Instead I cut how long each request holds a connection: faster queries, no remote calls inside transactions. I also check every outbound HTTP client has connect and read timeouts, because one slow dependency can tie up every thread. On a recent Boot version with Java 21, turning on virtual threads makes a blocked thread nearly free, which helps with lots of slow I/O. But it doesn't create database connections, so the pool can become the bottleneck even faster."
Raising every pool size at once without measuring, or thinking virtual threads remove the database connection limit.
Situation: what broke and how you noticed, briefly.
Trace: how you checked the active profile, properties or beans instead of guessing.
Fix: the quick fix, then the lasting guard, like a startup check or a test.
"At my last company, after a release, one service started sending real emails from staging. Nothing in the email code had changed. I checked the Actuator env endpoint on staging, which we kept on an internal port, and saw no active profile at all. A deployment change had renamed an environment variable, so SPRING_PROFILES_ACTIVE was never set. Our fake email sender was only registered under the staging profile, so with no profile the real sender was wired in instead. We paused the email job, restored the variable and redeployed within the hour. Then I fixed the root cause: the real sender now needs an explicit prod profile instead of being the fallback, and I added a startup check that fails if no profile is active. Since then a missing profile stops the app instead of quietly changing its behaviour."
Blaming the ops team without saying what you changed in the app, or a story that ends with no lasting guard against a repeat.
Prepare: move to the latest minor of the old line first and clear deprecation warnings.
Known breaks: namespace or Java baseline changes, security config, Hibernate behaviour, renamed properties.
Tools: the properties migrator and solid integration tests.
Rollout: one service first, compare metrics, then the rest.
"I led the move of a few services from Boot 2 to Boot 3. First we upgraded to the last 2.x release and fixed every deprecation warning, because that's where the framework tells you what's going away. Then the big jump. The Java baseline moved to 17, and every javax import became jakarta, which was mostly search and replace but also meant upgrading two libraries that weren't ready. Security config had to move from the old adapter class to SecurityFilterChain beans. Hibernate 6 changed some query behaviour, and one native query started returning a different type, which only our integration tests caught. We added the properties migrator for one release so it logged renamed properties, then removed it. We shipped one low-traffic service first, watched error rates and latency for a week, and did the rest with a checklist from that first one."
Jumping straight to the new major version without clearing deprecations or having integration tests to catch behaviour changes.
Measure: request timings from metrics or tracing, then narrow down to the slow part.
Cause: often queries, like N+1 or a missing index, or a blocking remote call.
Fix and prove: change one thing and compare before and after numbers.
"In my last project, the order history endpoint had become slow for customers with long histories, sometimes several seconds. Actuator metrics showed it as our slowest route, and a trace showed nearly all the time was in the database, spread over hundreds of tiny queries. Turning on SQL logging locally with realistic data made it obvious: we loaded the orders, then each order's items and each item's product lazily, a classic N+1 on two levels. I wrote a repository method with an entity graph for items and products, and changed the endpoint to return a paged DTO instead of the full list. I also added a test that counts queries, so it can't quietly come back. For the worst accounts it went from seconds to well under a quarter of a second, and database load dropped noticeably too."
Adding a cache first without knowing where the time goes, or claiming a speed-up with no before-and-after numbers.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.