Spring Boot Interview Questions and Answers
99 hand-picked Spring Boot interview questions with
detailed answers. Open the interactive version above to search, filter
by difficulty, run code, bookmark questions and track your progress.
What is Spring Boot and how does it differ from the Spring Framework?
Spring Boot is an opinionated layer on top of Spring. Spring gives you the IoC container, DI, and MVC but leaves assembly to you (XML, servlet config, dependency versions, a servlet container to deploy into). Spring Boot removes that assembly work with four things:
- Auto-configuration — it inspects the classpath and configures sensible beans automatically (see a DataSource driver, get a DataSource).
- Starters — one dependency (
spring-boot-starter-web) pulls a curated, version-aligned set. - Embedded server — Tomcat/Jetty/Undertow ship inside the jar, so it's
java -jar, not a WAR deployed to an external container. - Production-ready features — Actuator health, metrics, and externalized config out of the box.
Spring Boot does not replace Spring — every bean, proxy, and annotation is still core Spring underneath.
@SpringBootApplication
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}
Explain the core stereotype annotations.
@Component — the generic Spring-managed bean; the other three are specialisations of it.@Service — business logic. Semantically identical to @Component; it documents intent.@Repository — data access. Not just a marker: it enables exception translation, converting vendor-specific SQLExceptions into Spring's DataAccessException hierarchy.@Controller / @RestController — web endpoints. @RestController = @Controller + @ResponseBody, so returns are serialised to JSON instead of resolved as view names.@Configuration — a class declaring @Bean methods.
All are picked up by component scanning and registered in the ApplicationContext.
@Service
public class UserService { }
@Repository
public class UserDao { } // + SQLException -> DataAccessException
@RestController // = @Controller + @ResponseBody
public class UserController { }
What does @SpringBootApplication do?
It's a convenience meta-annotation combining three:
@SpringBootConfiguration — a @Configuration class; the primary source of bean definitions.@EnableAutoConfiguration — turns on the classpath-driven auto-configuration machinery.@ComponentScan — scans the annotated class's package and all sub-packages for components.
That last point has a practical consequence: the main class's package placement defines your scanning root.
package com.example.app; // <- scan root
@SpringBootApplication
public class App {
public static void main(String[] a) { SpringApplication.run(App.class, a); }
}
// com.example.app.service.* -> scanned
// com.other.util.* -> NOT scanned
What are Spring Boot starters?
Starters are curated dependency descriptors — a single POM that transitively pulls in a coherent, version-compatible set of libraries for one capability. spring-boot-starter-web brings Spring MVC, Jackson, validation, and embedded Tomcat in one line.
They solve two problems: you stop hunting for the right artifacts, and the parent BOM guarantees the versions actually work together, so you never specify a version yourself.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<!-- no version: the parent BOM decides -->
</dependency>
What is auto-configuration and how does Spring Boot decide what to configure?
Auto-configuration means Boot registers beans for you based on what it finds on the classpath and in your config, rather than you declaring them. Add H2 and it configures an in-memory DataSource; add spring-boot-starter-web and it configures a DispatcherServlet and embedded Tomcat.
The decisions are made by @Conditional annotations on auto-configuration classes:
@ConditionalOnClass — is this type on the classpath?@ConditionalOnMissingBean — has the user not defined their own?@ConditionalOnProperty — is this property set?
@AutoConfiguration
@ConditionalOnClass(DataSource.class)
public class DataSourceAutoConfiguration {
@Bean
@ConditionalOnMissingBean // backs off if you define one
public DataSource dataSource() { /* ... */ }
}
application.properties vs application.yml — what's the difference?
Purely a format choice; both feed the same Environment. Properties are flat key=value; YAML is hierarchical, less repetitive, and supports lists and multiple documents (profile blocks) in one file.
Two practical notes: if both exist, .properties wins for conflicting keys; and YAML is whitespace-sensitive, so indentation bugs are a real cost. Neither format supports the other's edge cases perfectly — YAML has surprises like on/off parsing as booleans.
# application.yml
spring:
datasource:
url: jdbc:postgresql://localhost/db
username: app
server:
port: 8081
# equivalent .properties
spring.datasource.url=jdbc:postgresql://localhost/db
spring.datasource.username=app
server.port=8081
How does Spring Boot resolve config when the same property is set in several places?
Boot layers PropertySources in a defined precedence order and the highest-priority one wins. Roughly, highest to lowest:
- Command-line arguments (
--server.port=9090) SPRING_APPLICATION_JSON- OS environment variables / JVM system properties
- Profile-specific config outside the jar (
application-prod.yml) - Profile-specific config inside the jar
- Plain
application.yml outside the jar, then inside the jar @PropertySourceSpringApplication.setDefaultProperties
The design intent: the same artifact runs in every environment, with the environment supplying overrides from outside.
# all three set server.port; the CLI arg wins
java -jar app.jar --server.port=9090
SERVER_PORT=8888 java -jar app.jar # env var (relaxed binding)
# application.yml inside the jar
server.port: 8080
@Value vs @ConfigurationProperties — when do you use each?
@Value("${...}") injects a single property, supports SpEL and defaults, and is fine for one-off values. @ConfigurationProperties binds a group of properties onto a typed object.
For anything beyond a value or two, @ConfigurationProperties is the better answer, because it gives you: type safety, relaxed binding, nested objects and lists, JSR-303 validation, and IDE auto-completion via the metadata processor. @Value gives none of those and scatters string keys across the codebase.
@ConfigurationProperties(prefix = "app.mail")
@Validated
public record MailProps(
@NotBlank String host,
@Min(1) int port,
List<String> recipients) {}
// app.mail.host=smtp.example.com
// app.mail.port=587
// app.mail.recipients[0]=ops@example.com
@Value("${app.timeout:5000}") // single value + default
private long timeoutMs;
What are bean scopes, and why is the singleton default dangerous?
The scope controls how many instances the container creates:
- singleton (default) — one instance per container, shared by every injection point.
- prototype — a new instance on every injection/lookup.
- request, session, application, websocket — web-aware scopes.
The danger: a singleton is shared across all concurrent request threads. Any mutable instance field on a @Service is shared state and a race condition. Spring beans must be stateless — that's the rule the scope default silently depends on.
@Service
public class BadService {
private int counter; // SHARED across every request thread!
public void handle() { counter++; } // race condition
}
@Service
public class GoodService {
public int handle(int input) { return input + 1; } // stateless
}
Walk through the Spring bean lifecycle.
For each bean the container: instantiates it (constructor, with constructor-injected deps), populates the remaining dependencies (fields/setters), runs *Aware callbacks, runs BeanPostProcessor.postProcessBeforeInitialization, calls initialisation callbacks (@PostConstruct → InitializingBean.afterPropertiesSet → custom initMethod), then runs postProcessAfterInitialization — this is where AOP proxies are created. The bean is then in use, and on shutdown @PreDestroy → DisposableBean.destroy → custom destroyMethod run (singletons only).
@Component
public class Lifecycle implements InitializingBean, DisposableBean {
public Lifecycle() { } // 1 instantiate
@Autowired void setDep(Dep d) { } // 2 populate
@PostConstruct void init() { } // 3 init
public void afterPropertiesSet() { } // 4
@PreDestroy void cleanup() { } // 5 shutdown
public void destroy() { } // 6
}
How does dependency injection work in Spring, and why is constructor injection preferred?
The IoC container owns object creation: it reads bean definitions, resolves the dependency graph, instantiates beans, and injects collaborators — you never new a service. Injection comes in three forms: constructor, setter, and field.
Constructor injection is the recommendation, because it makes dependencies explicit in the signature, allows final fields (immutable, thread-safe), fails fast at startup on a missing bean, exposes design smells (a 10-arg constructor screams that the class does too much), and lets you instantiate the class in a plain unit test with new — no Spring, no reflection.
Since Spring 4.3, a single constructor needs no @Autowired at all.
@Service
public class UserService {
private final UserRepo repo; // final = immutable
private final MailClient mail;
public UserService(UserRepo repo, MailClient mail) { // no @Autowired needed
this.repo = repo;
this.mail = mail;
}
}
// unit test — no Spring context at all
var svc = new UserService(mockRepo, mockMail);
Two beans implement the same interface — how does Spring know which to inject?
It doesn't, and startup fails with NoUniqueBeanDefinitionException. You disambiguate with:
@Primary — marks the default winner when nothing more specific applies. Good for "one obvious default".@Qualifier("name") — names the exact bean at the injection point. Beats @Primary.- Parameter name matching — a fallback: if the parameter is named after the bean, Spring resolves it.
- Inject
List<T> or Map<String,T> — take all implementations (the basis of the strategy pattern in Spring).
@Service @Primary
class EmailNotifier implements Notifier { }
@Service("sms")
class SmsNotifier implements Notifier { }
@Service
class OrderService {
OrderService(Notifier n, // -> EmailNotifier (@Primary)
@Qualifier("sms") Notifier sms, // -> SmsNotifier
List<Notifier> all, // -> both
Map<String, Notifier> byName) { } // -> {emailNotifier:.., sms:..}
}
How do you build a REST endpoint, and how do you bind inputs?
Annotate the class @RestController, map methods with @GetMapping/@PostMapping/@PutMapping/@PatchMapping/@DeleteMapping (all shorthands for @RequestMapping(method=...)), and put the shared prefix on the class.
Input binding:
@PathVariable — part of the path (/users/42): identifies a resource.@RequestParam — query string (?page=2): filters, paging, optional bits.@RequestBody — the JSON body, deserialised by Jackson.@RequestHeader, @CookieValue — headers and cookies.
Return a DTO (auto-serialised to JSON) or ResponseEntity when you need to control status and headers.
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService service;
UserController(UserService s) { this.service = s; }
@GetMapping("/{id}")
UserDto get(@PathVariable Long id) { return service.find(id); }
@GetMapping
Page<UserDto> list(@RequestParam(defaultValue = "0") int page,
@RequestParam(required = false) String name) {
return service.search(name, PageRequest.of(page, 20));
}
@PostMapping
ResponseEntity<UserDto> create(@Valid @RequestBody CreateUserRequest req) {
UserDto saved = service.create(req);
return ResponseEntity.created(URI.create("/api/users/" + saved.id())).body(saved);
}
}
How do you validate request payloads?
Bean Validation (Jakarta Validation / Hibernate Validator, via spring-boot-starter-validation). Annotate the DTO's fields (@NotBlank, @Email, @Min, @Size, @Pattern) and mark the controller parameter @Valid. A violation throws MethodArgumentNotValidException → 400.
Two important distinctions:
@Valid (Jakarta) triggers validation and cascades to nested objects. @Validated (Spring) adds validation groups and, on a class, enables method-level validation on non-controller beans.- Nested objects are only validated if the field itself carries
@Valid.
Pair it with a @RestControllerAdvice to turn violations into a clean field-level error response.
public record CreateOrderRequest(
@NotBlank String customerId,
@NotEmpty @Valid List<LineItem> items, // @Valid = cascade into LineItem
@Positive BigDecimal total) {}
@PostMapping
Order create(@Valid @RequestBody CreateOrderRequest req) { ... }
@RestControllerAdvice
class ValidationAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<Map<String,String>> onInvalid(MethodArgumentNotValidException e) {
Map<String,String> errors = e.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField,
f -> f.getDefaultMessage(), (a,b) -> a));
return ResponseEntity.badRequest().body(errors);
}
}
How do you handle exceptions globally in a REST API?
@RestControllerAdvice — a class of @ExceptionHandler methods applied across all controllers, translating exceptions into consistent JSON and correct status codes. It keeps controllers free of try/catch and guarantees one error contract.
Since Boot 3, prefer ProblemDetail (RFC 7807) as the response body — a standard error shape (type, title, status, detail, instance) instead of a bespoke one. Extend ResponseEntityExceptionHandler to also cover Spring's built-in MVC exceptions.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
ProblemDetail onNotFound(NotFoundException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
@ExceptionHandler(Exception.class) // safety net
ProblemDetail onUnexpected(Exception e) {
String id = UUID.randomUUID().toString();
log.error("unhandled [{}]", id, e); // full detail in logs
var pd = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error. Reference: " + id);
pd.setTitle("Internal Error");
return pd; // no stack trace to client
}
}
What are Spring profiles and how do you use them?
Profiles are named groups of config and beans that activate conditionally, so one build artifact behaves differently per environment. Two uses:
- Config —
application-dev.yml / application-prod.yml overlay the base application.yml when active. - Beans —
@Profile("dev") registers a bean only under that profile (an in-memory stub locally, the real client in prod).
Activate with spring.profiles.active, ideally from an env var (SPRING_PROFILES_ACTIVE=prod) — never hardcoded in the packaged jar.
@Bean
@Profile("!prod") // any profile EXCEPT prod
PaymentGateway stubGateway() { return new StubGateway(); }
@Bean
@Profile("prod")
PaymentGateway realGateway(@Value("${gateway.key}") String key) {
return new StripeGateway(key);
}
// activate:
// SPRING_PROFILES_ACTIVE=prod java -jar app.jar
// java -jar app.jar --spring.profiles.active=prod,metrics
What does Spring Boot Actuator provide?
Production-ready HTTP (and JMX) endpoints for operating the app:
/actuator/health — aggregated health, with liveness/readiness probes for Kubernetes./actuator/metrics and /actuator/prometheus — Micrometer metrics for scraping./actuator/info, /env, /configprops, /beans, /mappings, /loggers (change log levels at runtime, no restart), /threaddump, /heapdump.
Only health is exposed over HTTP by default — you opt the rest in explicitly.
management:
endpoints:
web:
exposure:
include: health,info,prometheus # explicit allow-list, never '*'
endpoint:
health:
show-details: when-authorized
probes:
enabled: true # /health/liveness, /health/readiness
server:
port: 9090 # separate port, not publicly routed
What is Spring Data JPA and how does it generate repositories?
It removes DAO boilerplate: you declare an interface extending JpaRepository<Entity, Id> and Spring creates the implementation at runtime as a proxy. You get CRUD, paging, and sorting for free, plus derived queries parsed from method names (findByEmailAndActiveTrue) and @Query for anything complex.
The hierarchy: Repository → CrudRepository → PagingAndSortingRepository → JpaRepository (adds JPA specifics like flush() and saveAndFlush()).
public interface UserRepo extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByActiveTrueOrderByCreatedAtDesc();
boolean existsByEmail(String email);
Page<User> findByLastNameContainingIgnoreCase(String s, Pageable p);
@Query("select u from User u join fetch u.roles where u.id = :id")
Optional<User> findWithRoles(@Param("id") Long id);
}
What does @Transactional do, and what's the self-invocation pitfall?
It declares a transaction boundary: Spring opens a transaction before the method and commits on normal return, or rolls back on a runtime exception. It works via an AOP proxy — the caller talks to a proxy that starts/commits around the real method.
Two consequences everyone must know:
- Default rollback is unchecked exceptions only. A checked exception commits! Use
rollbackFor = Exception.class if you need otherwise. - Self-invocation doesn't work. Calling
this.otherMethod() bypasses the proxy, so its @Transactional is ignored — silently.
@Transactional on a private or final method is also silently ignored — the proxy can't override it.
@Service
public class OrderService {
@Transactional
public void placeOrder(Order o) { repo.save(o); }
public void bulk(List<Order> orders) {
for (Order o : orders) {
placeOrder(o); // SELF-CALL -> bypasses proxy -> NO transaction!
}
}
}
Explain transaction propagation. When would you use REQUIRES_NEW?
Propagation decides what happens when a transactional method is called while a transaction is already active:
- REQUIRED (default) — join the existing one, or start one if none. Everything commits or rolls back together.
- REQUIRES_NEW — suspend the caller's transaction and run in a genuinely independent one. It commits even if the outer rolls back.
- NESTED — a savepoint inside the current transaction; can roll back to the savepoint without killing the outer.
- SUPPORTS / NOT_SUPPORTED / MANDATORY / NEVER — join if present / suspend / require / forbid.
REQUIRES_NEW's canonical use: audit or failure logging that must survive the rollback of the business transaction.
@Service
public class OrderService {
@Transactional // REQUIRED
public void placeOrder(Order o) {
repo.save(o);
auditService.log("attempt", o); // separate tx — survives rollback
payment.charge(o); // throws -> order rolled back...
} // ...but the audit row remains
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(String event, Order o) { auditRepo.save(new Audit(event, o)); }
}
What is the N+1 select problem and how do you fix it?
You load N parents with one query, then touch a lazy association on each — firing one extra query per parent. 100 orders with order.getItems() in a loop = 1 + 100 queries. It passes every test on 5 rows of dev data and destroys production.
Fixes, roughly in order of preference:
join fetch in a @Query — load the association in the same query.@EntityGraph — declarative fetch plan on a repository method; composes with derived queries and paging.@BatchSize — collapses N queries into N/batch IN queries; the best option when you also need pagination.- Project straight into a DTO so the association is never traversed.
// N+1: 1 query for orders + 1 per order for items
List<Order> orders = orderRepo.findAll();
orders.forEach(o -> o.getItems().size());
// Fix 1 — join fetch
@Query("select distinct o from Order o join fetch o.items where o.status = :s")
List<Order> findWithItems(@Param("s") Status s);
// Fix 2 — entity graph
@EntityGraph(attributePaths = {"items", "customer"})
List<Order> findByStatus(Status s);
// Fix 3 — batch fetching (works WITH pagination)
@OneToMany(mappedBy = "order")
@BatchSize(size = 50)
private List<LineItem> items;
Lazy vs eager loading — and what causes LazyInitializationException?
Lazy loads an association only when first accessed (via a Hibernate proxy); eager loads it immediately with the parent. Defaults: @OneToMany and @ManyToMany are LAZY; @ManyToOne and @OneToOne are EAGER — which surprises people and quietly causes join explosions.
LazyInitializationException happens when you touch a lazy association after the persistence context closed — typically in the controller or during JSON serialisation, once the @Transactional service method has returned.
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY) // override the EAGER default
private Customer customer;
@OneToMany(mappedBy = "order") // LAZY by default
private List<LineItem> items;
}
// throws LazyInitializationException:
Order o = service.find(id); // tx ends here, session closed
o.getItems().size(); // no session -> boom
How does pagination work in Spring Data, and Page vs Slice?
Accept a Pageable in the repository method and Spring Data applies limit/offset and ordering. Return type decides the cost:
Page<T> — the content plus a COUNT query for the total, so you can render "page 3 of 47".Slice<T> — content plus a cheap "is there more?" flag (it fetches size+1 rows). No count query — right for infinite scroll.List<T> — just the window, nothing extra.
With @PageableDefault a controller can bind ?page=2&size=20&sort=createdAt,desc automatically.
@GetMapping
Page<UserDto> list(@PageableDefault(size = 20, sort = "createdAt",
direction = Sort.Direction.DESC) Pageable p) {
return repo.findByActiveTrue(p).map(UserDto::from);
}
// repository
Page<User> findByActiveTrue(Pageable p); // + SELECT count(*)
Slice<User> findByActiveTrue(Pageable p); // no count — infinite scroll
Optimistic vs pessimistic locking — how do you prevent lost updates?
The lost-update problem: two transactions read the same row, both modify it, the second write silently overwrites the first.
- Optimistic locking — add a
@Version column. Each update includes WHERE version = ? and bumps it; if the row was changed meanwhile, 0 rows match and Hibernate throws OptimisticLockException. No DB locks, great throughput, but the loser must retry. - Pessimistic locking —
SELECT ... FOR UPDATE takes a real DB lock so others block until you commit. No retries, but it serialises access and risks deadlock and lock-wait timeouts.
Default to optimistic; use pessimistic only for short, hot, high-contention critical sections (inventory decrement, seat booking).
@Entity
public class Account {
@Id private Long id;
@Version private Long version; // Hibernate manages it entirely
private BigDecimal balance;
}
// UPDATE account SET balance=?, version=6 WHERE id=? AND version=5
// -> 0 rows updated => OptimisticLockException
// pessimistic
public interface SeatRepo extends JpaRepository<Seat, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE) // SELECT ... FOR UPDATE
@Query("select s from Seat s where s.id = :id")
Optional<Seat> findByIdForUpdate(@Param("id") Long id);
}
What is the persistence context, and why does an entity update without calling save()?
The persistence context (Hibernate's Session / JPA's EntityManager) is a first-level cache and unit of work scoped to the transaction. Every entity you load or persist becomes managed — Hibernate keeps a snapshot of its loaded state.
At flush time (before a query, or at commit) it compares each managed entity against its snapshot — dirty checking — and auto-generates UPDATEs for whatever changed. So inside a transaction, mutating a loaded entity persists without any save() call. Calling save() on an already-managed entity is a no-op.
Entity states: transient (new, unknown) → managed (tracked) → detached (context closed) → removed.
@Transactional
public void rename(Long id, String name) {
User u = repo.findById(id).orElseThrow(); // managed + snapshot taken
u.setName(name); // no save() call...
} // ...commit -> dirty check -> UPDATE
@Transactional(readOnly = true) // no snapshots, no dirty check
public UserDto view(Long id) { ... }
@Query — JPQL vs native SQL, and how do you avoid SQL injection?
@Query writes the query yourself when derived method names run out. JPQL (default) queries entities and fields, is database-portable, and is validated at startup. Native (nativeQuery = true) is raw SQL against tables — needed for vendor features (window functions, CTEs, ON CONFLICT) at the cost of portability and startup validation.
Injection safety: always use named parameters (:email) or positional (?1). These become JDBC bind parameters, which the driver never interprets as SQL. Concatenating user input into a query string is the vulnerability.
// JPQL — entity names and fields, not tables and columns
@Query("select u from User u where u.email = :email and u.active = true")
Optional<User> findActiveByEmail(@Param("email") String email); // safely bound
// native — vendor-specific power
@Query(value = """
select * from users u
where u.tsv @@ plainto_tsquery('english', :q)
order by ts_rank(u.tsv, plainto_tsquery('english', :q)) desc
""", nativeQuery = true)
List<User> fullTextSearch(@Param("q") String q);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("update User u set u.active = false where u.lastLogin < :cutoff")
int deactivateStale(@Param("cutoff") Instant cutoff);
How do you manage database schema changes? Why not hibernate ddl-auto?
With a migration tool — Flyway (versioned SQL files) or Liquibase (XML/YAML/JSON changelogs, DB-agnostic). Boot auto-runs them at startup before Hibernate initialises. Each migration is versioned, checksummed, and recorded in a history table so it applies exactly once, in order, identically in every environment.
spring.jpa.hibernate.ddl-auto=update must never be used in production: it's non-deterministic, it never drops or renames anything, it can't do data migrations, it has no rollback, and it silently diverges between environments. The schema is production state — it needs version control and review, not inference from entity classes.
# src/main/resources/db/migration/V1__create_users.sql
# V2__add_email_index.sql (immutable once applied — checksummed)
spring:
flyway:
enabled: true
baseline-on-migrate: true # adopting an existing DB
jpa:
hibernate:
ddl-auto: validate # prod: verify only, never modify
---
spring:
config:
activate:
on-profile: test
jpa:
hibernate:
ddl-auto: create-drop # throwaway test DB only
How does the connection pool work, and how do you size it?
Boot uses HikariCP by default. Opening a TCP connection and authenticating to a DB costs milliseconds; a pool keeps a set of live connections and hands them out, so a request borrows one and returns it at transaction end.
Sizing is counter-intuitive: small pools outperform large ones. A DB has a finite number of cores and disks; past that, more concurrent connections just add context-switching and lock contention while each query gets slower. HikariCP's own guidance is roughly connections = ((core_count * 2) + effective_spindle_count) — often just 10–20, even for high traffic.
The other critical knob is connection-timeout: how long a thread waits for a connection before failing fast rather than hanging.
spring:
datasource:
hikari:
maximum-pool-size: 15 # small on purpose
minimum-idle: 15 # = max: avoid churn in steady state
connection-timeout: 3000 # fail fast, don't hang the thread
idle-timeout: 600000
max-lifetime: 1800000 # < DB/proxy idle kill time!
leak-detection-threshold: 20000 # log a stack trace for held connections
validation-timeout: 3000
How does Spring's caching abstraction work?
@EnableCaching plus annotations, backed by a pluggable CacheManager (Caffeine, Redis, Hazelcast, or a simple ConcurrentHashMap). It's proxy-based, like @Transactional.
@Cacheable — check the cache first; on a miss, run the method and store the result.@CachePut — always run the method and update the cache (for writes).@CacheEvict — remove an entry (or everything with allEntries=true).@Caching — combine several.
The abstraction means you switch from Caffeine (local) to Redis (distributed) by changing a dependency and config, not code.
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id", unless = "#result == null")
public Product find(Long id) { return repo.findById(id).orElse(null); }
@CachePut(value = "products", key = "#p.id") // always executes, refreshes cache
public Product update(Product p) { return repo.save(p); }
@CacheEvict(value = "products", key = "#id")
public void delete(Long id) { repo.deleteById(id); }
@CacheEvict(value = "products", allEntries = true)
@Scheduled(fixedRate = 3_600_000)
public void flushAll() { }
}
How does Spring Security work? Explain the filter chain.
Spring Security is a chain of servlet filters that runs before the DispatcherServlet. A single DelegatingFilterProxy hands off to the FilterChainProxy, which picks a matching SecurityFilterChain and runs its ordered filters.
The key ones: SecurityContextPersistenceFilter (restore the context), UsernamePasswordAuthenticationFilter (form login), BearerTokenAuthenticationFilter (JWT), ExceptionTranslationFilter (turn auth failures into 401/403), and AuthorizationFilter (enforce access rules) last.
Authentication succeeds → an Authentication lands in the SecurityContextHolder (a ThreadLocal) → authorization checks read it.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // stateless API only!
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) // deny-by-default, last
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
}
How does JWT authentication work, and what are its trade-offs?
On login the server verifies credentials and returns a signed JWT (header.payload.signature). The client sends it as Authorization: Bearer <token>; a filter validates the signature and expiry and populates the SecurityContext. Because the token is self-contained and signed, the server stores no session — any instance can validate it, which is why it suits horizontally-scaled and multi-service systems.
The trade-off that defines JWT: you cannot revoke one. It's valid until it expires. Logout, a ban, or a permission change won't stop a token already in the wild. The standard mitigation is short-lived access tokens (5–15 min) plus a long-lived, revocable refresh token stored server-side.
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.csrf(c -> c.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(j -> j.jwtAuthenticationConverter(conv())))
.build();
}
@Bean
JwtDecoder jwtDecoder() { // validates signature + exp + iss
return NimbusJwtDecoder.withJwkSetUri("https://idp/.well-known/jwks.json").build();
}
How do you do method-level authorization?
@EnableMethodSecurity, then annotate methods:
@PreAuthorize — a SpEL check before the call; has access to the arguments and the authentication.@PostAuthorize — checks after, against returnObject (for "you may only read your own record" checks that need the loaded object).@PreFilter / @PostFilter — strip elements from a collection argument/return.@Secured / @RolesAllowed — simple role checks, no SpEL.
This complements URL rules rather than replacing them: URL security is coarse and can be bypassed if another entry point reaches the service, whereas method security sits next to the business rule.
@Service
@EnableMethodSecurity // on a @Configuration class
public class DocumentService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteAll() { }
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.name")
public User find(String userId) { } // args available in SpEL
@PostAuthorize("returnObject.ownerId == authentication.name")
public Document load(Long id) { } // check the loaded object
@PreAuthorize("@docPermissions.canEdit(#id, authentication)") // custom bean
public void edit(Long id) { }
}
How should passwords be stored?
Hashed with a slow, salted, adaptive algorithm — BCrypt, Argon2, or SCrypt — never encrypted, never plain, and never a fast hash like MD5/SHA-256.
The reasoning: general-purpose hashes are designed to be fast, which is exactly wrong here — a GPU does billions of SHA-256/sec, so a leaked table is cracked in hours. BCrypt is deliberately slow and has a work factor you raise as hardware improves. It also salts every password automatically, so identical passwords produce different hashes and precomputed rainbow tables are useless.
In Spring, use DelegatingPasswordEncoder (the default from PasswordEncoderFactories) — it prefixes the hash with its algorithm id so you can migrate algorithms later without a big-bang reset.
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// stored: {bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMye...
// ^ algorithm id -> lets you migrate encoders later
String hash = encoder.encode(raw); // salt generated internally
boolean ok = encoder.matches(raw, hash); // never decode — always compare
How do you enable CORS, and what problem does it actually solve?
CORS is a browser mechanism that relaxes the same-origin policy. Your Angular app on localhost:4200 calling an API on localhost:8080 is a cross-origin request; the browser blocks reading the response unless the server returns Access-Control-Allow-Origin naming that origin.
Configure globally via WebMvcConfigurer (or CorsConfigurationSource when Spring Security is present — security must know about it too), or per-controller with @CrossOrigin.
Crucially: CORS is not a server-side security control. It protects users' browsers from other sites reading your responses with their cookies. It stops nothing from curl or a server.
@Bean
CorsConfigurationSource corsConfigurationSource() {
var c = new CorsConfiguration();
c.setAllowedOrigins(List.of("https://app.example.com")); // explicit, not "*"
c.setAllowedMethods(List.of("GET","POST","PUT","DELETE"));
c.setAllowedHeaders(List.of("Authorization","Content-Type"));
c.setAllowCredentials(true);
c.setMaxAge(3600L); // cache preflight 1h
var src = new UrlBasedCorsConfigurationSource();
src.registerCorsConfiguration("/api/**", c);
return src;
}
// and in the security chain — otherwise preflight gets 401'd:
http.cors(Customizer.withDefaults())
What is CSRF, and when is it safe to disable CSRF protection?
CSRF tricks an authenticated user's browser into making a state-changing request to your site. Because browsers attach cookies automatically to any request to your domain, a hidden form on evil.com can POST to your bank and the request arrives fully authenticated.
Spring's defence is a synchroniser token: the server issues an unpredictable token, forms echo it, and the server rejects requests without it. The attacker can send cookies but can't read your token (that's the same-origin policy).
Disabling it is safe only when authentication isn't ambient — i.e. a stateless API using an Authorization: Bearer header, since a browser never attaches that automatically. If you authenticate with cookies or a session, CSRF protection is required, API or not.
// Stateless, Bearer-token API -> disabling is correct
http.csrf(c -> c.disable())
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS));
// Cookie/session auth or a JWT in an httpOnly cookie -> KEEP it
http.csrf(c -> c
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // SPA reads it
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()));
@SpringBootTest vs test slices — how do you structure tests?
@SpringBootTest loads the whole context — thorough but slow, and it makes failures hard to localise. Test slices load only one layer:
@WebMvcTest — controllers, JSON, validation, security filters; no services or repos (mock them).@DataJpaTest — JPA, repositories, and an embedded DB; transactional and rolled back per test.@JsonTest, @RestClientTest, @DataRedisTest, etc.
The pyramid in practice: mostly plain JUnit unit tests with Mockito (no Spring at all — milliseconds), a layer of slices, and a small number of full @SpringBootTest tests for critical end-to-end paths.
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired MockMvc mvc;
@MockitoBean UserService service; // @MockBean before Boot 3.4
@Test
void returns404WhenMissing() throws Exception {
given(service.find(1L)).willThrow(new NotFoundException("nope"));
mvc.perform(get("/api/users/1"))
.andExpect(status().isNotFound());
}
}
@DataJpaTest // embedded DB, rolled back per test
class UserRepoTest {
@Autowired UserRepo repo;
@Test void findsByEmail() {
repo.save(new User("a@b.com"));
assertThat(repo.findByEmail("a@b.com")).isPresent();
}
}
How do you unit test a service, and when do you mock?
Constructor injection means a service is a plain object — instantiate it with Mockito mocks and no Spring context at all. @ExtendWith(MockitoExtension.class) plus @Mock/@InjectMocks wires it in milliseconds.
What to mock: things you don't own or that are slow/non-deterministic — repositories, HTTP clients, message publishers, the clock. What not to mock: value objects, DTOs, the class under test, and (usually) types you don't own directly — prefer wrapping a third-party client in your own interface and mocking that.
Over-mocking produces tests that assert your mocks were called in the order you wrote them — they pass while production is broken.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock UserRepo repo;
@Mock MailClient mail;
@InjectMocks OrderService service; // no Spring context — milliseconds
@Test
void sendsConfirmationOnSuccess() {
given(repo.findById(1L)).willReturn(Optional.of(new User("a@b.com")));
service.placeOrder(1L, cart);
then(mail).should().send(eq("a@b.com"), contains("confirmed"));
}
@Test
void throwsWhenUserMissing() {
given(repo.findById(9L)).willReturn(Optional.empty());
assertThatThrownBy(() -> service.placeOrder(9L, cart))
.isInstanceOf(NotFoundException.class);
then(mail).shouldHaveNoInteractions();
}
}
RestTemplate vs WebClient vs RestClient — which do you use?
- RestTemplate — the classic blocking client. In maintenance mode since Spring 5: not deprecated, still supported, but receiving no new features.
- WebClient — reactive and non-blocking, fluent API. Works in a plain MVC app too (call
.block()), which is why it became the default recommendation for a while. - RestClient (Spring 6.1+) — the modern answer for blocking code: WebClient's fluent API with synchronous semantics and no reactive dependency.
So: RestClient for a blocking MVC app, WebClient if you're on WebFlux or need real concurrency/streaming, and don't start anything new on RestTemplate.
Whichever you pick, the non-negotiable part is timeouts — the defaults are effectively infinite.
@Bean
RestClient restClient(RestClient.Builder builder) {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(2));
factory.setReadTimeout(Duration.ofSeconds(5)); // ALWAYS set these
return builder
.baseUrl("https://api.example.com")
.requestFactory(factory)
.defaultHeader("X-App", "orders")
.build();
}
User u = restClient.get()
.uri("/users/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError,
(req, res) -> { throw new NotFoundException("user " + id); })
.body(User.class);
How does @Async work, and what's wrong with the default executor?
@EnableAsync + @Async makes a method run on a different thread via a proxy; it returns immediately with void, Future, or CompletableFuture.
The default executor is the problem. Historically Boot used a SimpleAsyncTaskExecutor, which creates a brand-new thread per call and never pools — under load that's unbounded thread creation and OOM. Always define your own ThreadPoolTaskExecutor with an explicit core/max size, a bounded queue, and a rejection policy.
Same proxy caveats as always: self-invocation runs synchronously, and @Async on a private method does nothing — both silently.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Bean("taskExecutor")
public Executor taskExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(8);
ex.setMaxPoolSize(16);
ex.setQueueCapacity(100); // BOUNDED — never Integer.MAX_VALUE
ex.setThreadNamePrefix("async-"); // shows up in logs & thread dumps
ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
ex.setWaitForTasksToCompleteOnShutdown(true);
ex.setAwaitTerminationSeconds(30);
ex.initialize();
return ex;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> log.error("async {} failed", method.getName(), ex);
}
}
How does @Scheduled work, and what breaks when you run multiple instances?
@EnableScheduling + @Scheduled with fixedRate, fixedDelay, or a cron expression. The method must take no arguments and return void.
Two things bite in production:
- The default scheduler is a single thread. One long-running job delays every other scheduled task in the app.
- Every instance runs the job. Scale to 5 pods and your nightly billing job runs 5 times — concurrently. Scheduling is per-JVM and has no cluster awareness at all.
The fix for the second is a distributed lock — ShedLock is the usual lightweight answer, or Quartz with a JDBC store for real job management.
@Component
public class ReportJob {
@Scheduled(cron = "0 0 2 * * *", zone = "Europe/London") // 02:00 daily
@SchedulerLock(name = "nightlyReport", // ShedLock
lockAtMostFor = "30m", lockAtLeastFor = "1m")
public void nightly() { }
@Scheduled(fixedDelay = 5000) // 5s AFTER the previous run finishes
public void poll() { }
@Scheduled(fixedRate = 5000) // every 5s regardless of duration
public void tick() { }
}
spring:
task:
scheduling:
pool:
size: 5 # default is 1!
How do Spring application events work, and why use @TransactionalEventListener?
ApplicationEventPublisher.publishEvent(obj) and @EventListener give you in-process pub/sub for decoupling: the order service announces "an order was placed" without knowing that email, analytics, and inventory care.
By default listeners are synchronous and run in the publisher's thread and transaction — so a listener throwing rolls back the publisher's work. That's often surprising.
@TransactionalEventListener ties the listener to the transaction lifecycle instead, most usefully AFTER_COMMIT: the side effect fires only if the transaction actually committed. That solves the classic "we emailed the customer, then the transaction rolled back" bug.
public record OrderPlacedEvent(Long orderId, String email) {} // immutable
@Service
public class OrderService {
private final ApplicationEventPublisher events;
@Transactional
public void placeOrder(Order o) {
repo.save(o);
events.publishEvent(new OrderPlacedEvent(o.getId(), o.getEmail()));
} // <- commit happens here
}
@Component
public class EmailListener {
@Async // don't block the caller
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlacedEvent e) {
mail.sendConfirmation(e.email(), e.orderId()); // only if committed
}
}
How does Spring AOP work? JDK proxy vs CGLIB.
AOP factors out cross-cutting concerns (transactions, security, caching, logging, metrics) into aspects applied around your beans. Spring implements it with runtime proxies: at postProcessAfterInitialization, the container wraps the bean and callers get the proxy.
Two proxy mechanisms:
- JDK dynamic proxy — only works if the bean implements an interface; the proxy implements the same interface.
- CGLIB — generates a subclass at runtime and overrides methods. Works without interfaces, but cannot proxy
final classes or final/private/static methods.
Spring Boot forces CGLIB by default (proxyTargetClass=true) — this is why @Transactional on a final method silently does nothing.
@Aspect
@Component
public class TimingAspect {
@Around("@annotation(com.example.Timed)") // or execution(* com.example.service..*(..))
public Object time(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
return pjp.proceed(); // MUST call proceed() or the method never runs
} finally {
log.info("{} took {}ms", pjp.getSignature().getName(),
(System.nanoTime() - start) / 1_000_000);
}
}
}
What is a circuit breaker and how do you use Resilience4j?
A circuit breaker stops you from hammering a dependency that's already failing. It tracks the failure rate and moves between three states:
- CLOSED — calls pass through; failures counted.
- OPEN — the failure threshold was crossed; calls fail immediately without touching the dependency (fail fast) and the fallback runs.
- HALF_OPEN — after a wait, a few probe calls are allowed. Succeed → CLOSED; fail → OPEN again.
The point is twofold: protect yourself (a dead dependency shouldn't consume your threads waiting on timeouts) and protect the dependency (stop piling load on a struggling service so it can recover).
Resilience4j is the standard choice — Hystrix is end-of-life.
@Service
public class InventoryClient {
@CircuitBreaker(name = "inventory", fallbackMethod = "fallback")
@Retry(name = "inventory") // retry runs INSIDE the breaker
@TimeLimiter(name = "inventory")
public Stock check(Long sku) {
return restClient.get().uri("/stock/{sku}", sku).retrieve().body(Stock.class);
}
private Stock fallback(Long sku, Throwable t) { // signature: args + Throwable
log.warn("inventory down, serving cached", t);
return Stock.unknown(sku); // degrade, don't die
}
}
How do microservices communicate? Synchronous vs asynchronous.
Synchronous (REST/gRPC): the caller blocks for a response. Simple, easy to debug, immediately consistent — but it creates temporal coupling: if the callee is down, you're down. Chains multiply: five services at 99.9% in a chain give you 99.5%, and latency is additive.
Asynchronous (Kafka/RabbitMQ): publish an event and move on. The consumer can be down and catch up later, so the services are decoupled in time and you get natural buffering and fan-out. The cost is eventual consistency, harder debugging, and out-of-order/duplicate delivery you must handle.
The rule of thumb: synchronous when the caller genuinely needs the answer to proceed (checking stock before confirming an order); asynchronous for notifying others that something happened (order placed → email, analytics, inventory).
// async — fire and forget, decoupled
@Service
public class OrderService {
private final KafkaTemplate<String, OrderEvent> kafka;
@Transactional
public void place(Order o) {
repo.save(o);
outbox.save(new OutboxEntry("order.placed", o)); // same tx — outbox pattern
}
}
@KafkaListener(topics = "order.placed", groupId = "email-service")
public void onOrderPlaced(OrderEvent e) {
if (processed.contains(e.id())) return; // idempotent consumer
mail.sendConfirmation(e);
}
How do you make an API idempotent, and why does it matter?
Idempotent = doing it N times has the same effect as doing it once. It matters because network timeouts are ambiguous: when a POST /payments times out, the client cannot know whether the charge happened and the response was lost. Retrying might double-charge; not retrying might drop a real order. Only idempotency resolves it.
By HTTP semantics GET, PUT, DELETE are idempotent; POST is not. The standard fix is an idempotency key: the client generates a unique key per logical operation and sends it as a header. The server records the key with the result; a repeat of the same key returns the stored response without re-executing.
@PostMapping("/payments")
public ResponseEntity<Payment> pay(
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody PaymentRequest req) {
var existing = idempotencyRepo.findByKey(key);
if (existing.isPresent()) {
return ResponseEntity.ok(existing.get().response()); // replay, don't re-charge
}
try {
Payment p = paymentService.charge(req);
idempotencyRepo.save(new IdempotencyRecord(key, p)); // unique constraint on key
return ResponseEntity.status(201).body(p);
} catch (DataIntegrityViolationException race) {
return ResponseEntity.ok(idempotencyRepo.findByKey(key).orElseThrow().response());
}
}
How do you make a Spring Boot service observable?
Three pillars, all first-class in Boot 3 via Micrometer:
- Metrics — Micrometer is the vendor-neutral facade (SLF4J for metrics): counters, gauges, timers, exported to Prometheus via
/actuator/prometheus. Good for rates, latency percentiles, saturation. - Tracing — Micrometer Tracing (which replaced Spring Cloud Sleuth) propagates a trace id across service boundaries, so you can see a request's full path through 6 services in Zipkin/Tempo/Jaeger.
- Logging — structured (JSON) logs with the trace id in the MDC, so a trace links directly to its logs.
The thing that ties them together is correlation: one trace id in the logs, the metrics, and the trace.
@Service
public class OrderService {
private final MeterRegistry registry;
@Observed(name = "order.place") // creates a span AND a timer
public Order place(Order o) {
registry.counter("orders.placed", "channel", o.channel()).increment();
return repo.save(o);
}
}
management:
endpoints.web.exposure.include: health,prometheus
tracing:
sampling:
probability: 0.1 # 10% — 100% is too expensive at scale
metrics.distribution.percentiles-histogram.http.server.requests: true
logging.pattern.level: "%5p [${spring.application.name},%X{traceId:-},%X{spanId:-}]"
Spring MVC vs WebFlux — when would you actually choose reactive?
MVC is thread-per-request on a servlet container: a request occupies a thread for its whole life, blocking on I/O. Simple to write and debug; the ceiling is the thread pool (200 threads = 200 concurrent in-flight requests) and each thread costs ~1MB of stack.
WebFlux is an event loop with a handful of threads: a request never blocks — it registers a callback and the thread serves someone else. Huge concurrency on tiny resources, plus backpressure (the consumer signals how much it can take).
When to choose it honestly: many concurrent, mostly-idle connections (streaming, SSE/WebSockets, a gateway fanning out to many services). Not for CPU-bound work, and not for a normal CRUD app — reactive code is markedly harder to write, debug (no usable stack traces), and staff, and one blocking call anywhere poisons the whole event loop.
Since Boot 3.2, virtual threads give MVC most of the scalability benefit with none of the complexity — which weakens the case for WebFlux considerably.
// MVC — blocking, one thread per request
@GetMapping("/{id}")
public User get(@PathVariable Long id) { return repo.findById(id).orElseThrow(); }
// WebFlux — non-blocking all the way down (needs R2DBC, not JDBC!)
@GetMapping("/{id}")
public Mono<User> get(@PathVariable Long id) { return repo.findById(id); }
// streaming — the genuine WebFlux use case
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Event> stream() {
return service.events().delayElements(Duration.ofSeconds(1));
}
What are virtual threads and what do they change for Spring Boot?
Virtual threads (Project Loom, Java 21) are lightweight threads managed by the JVM rather than the OS. A platform thread maps 1:1 to an OS thread and costs ~1MB of stack, so ~a few thousand is the practical ceiling. A virtual thread costs a few hundred bytes and millions can exist.
The magic: when a virtual thread blocks on I/O, the JVM unmounts it from its carrier platform thread and runs someone else. So blocking code no longer wastes a thread — you get the scalability of async with ordinary imperative code, real stack traces, working debuggers, and JDBC.
In Boot 3.2+ it's one property: spring.threads.virtual.enabled=true. Tomcat then uses a virtual thread per request and the 200-thread ceiling disappears.
spring:
threads:
virtual:
enabled: true # Boot 3.2+, Java 21+
# that's it — Tomcat now runs each request on a virtual thread,
# @Async and @Scheduled use virtual threads too.
// this blocking code now scales to tens of thousands of concurrent requests:
@GetMapping("/{id}")
public Order get(@PathVariable Long id) {
var user = userClient.fetch(id); // blocks — but unmounts, costs nothing
var stock = stockClient.check(id); // blocks
return new Order(user, stock);
}
How do you deploy without dropping requests? Explain graceful shutdown.
On SIGTERM, Boot's graceful shutdown stops accepting new requests but lets in-flight ones finish, up to a timeout, then closes the context (running @PreDestroy).
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
But that alone still drops requests in Kubernetes, because of a race: the pod gets SIGTERM and the endpoint removal from the load balancer happens concurrently and asynchronously. For a second or two the LB is still routing to a pod that has stopped accepting. The fix is a preStop sleep — keep serving for a few seconds after SIGTERM so the deregistration propagates first.
Combine that with a readiness probe that flips to out-of-service on shutdown (management.endpoint.health.probes.enabled=true).
# application.yml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
management:
endpoint:
health:
probes:
enabled: true
# deployment.yaml
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"] # keep serving while the LB deregisters
terminationGracePeriodSeconds: 45 # MUST exceed preStop + shutdown timeout
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
How do you write a custom auto-configuration / starter?
Create an @AutoConfiguration class with @Conditional guards and @Bean methods, bind config via @ConfigurationProperties, and register it in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports — one fully-qualified class name per line.
That file location is the Boot 3 change everyone trips on: it replaced META-INF/spring.factories. Forget it and your auto-configuration is simply never loaded, with no error at all.
Convention: two modules — acme-spring-boot-autoconfigure (the code) and acme-spring-boot-starter (an empty POM of dependencies). Third-party naming is acme-spring-boot-starter, never spring-boot-starter-acme — that prefix is reserved for the Spring team.
@AutoConfiguration
@ConditionalOnClass(AuditClient.class)
@ConditionalOnProperty(prefix = "acme.audit", name = "enabled",
havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(AuditProperties.class)
public class AuditAutoConfiguration {
@Bean
@ConditionalOnMissingBean // ALWAYS — let consumers override
public AuditClient auditClient(AuditProperties props) {
return new AuditClient(props.getUrl(), props.getTimeout());
}
}
// src/main/resources/META-INF/spring/
// org.springframework.boot.autoconfigure.AutoConfiguration.imports
// com.acme.audit.AuditAutoConfiguration
Filter vs Interceptor vs AOP — which do you use when?
Three layers, each seeing progressively more context:
- Filter (Servlet API) — outermost. Sees every request including static resources and errors, before Spring MVC exists. It can wrap/replace the request and response. Use for: security, CORS, request logging, compression, correlation ids.
- Interceptor (Spring MVC) — inside the DispatcherServlet, so it knows the handler method that will run. Use for: auth checks needing the target method, adding model attributes, timing controllers.
- AOP aspect — around any Spring bean method, with typed arguments and the return value. Use for: transactions, caching, business-level logging, retries.
Rule of thumb: the further out, the broader the reach but the less context. Pick the innermost layer that has what you need.
// Filter — runs for EVERYTHING, before Spring MVC
@Component
@Order(1)
public class CorrelationIdFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws Exception {
String id = Optional.ofNullable(req.getHeader("X-Correlation-Id"))
.orElse(UUID.randomUUID().toString());
MDC.put("correlationId", id);
res.setHeader("X-Correlation-Id", id);
try { chain.doFilter(req, res); }
finally { MDC.clear(); } // MUST clear — pooled threads leak it!
}
}
// Interceptor — knows the handler method
@Component
public class AuditInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
if (handler instanceof HandlerMethod hm) {
log.info("calling {}", hm.getMethod().getName());
}
return true; // false = stop the chain
}
}
Why use DTOs instead of exposing entities, and how do you map them?
Never return JPA entities from a controller. Four concrete reasons:
- Security — the entity has
passwordHash, internalNotes, isFraudFlagged. Serialising it leaks them, and any new column is auto-exposed by default. - Coupling — your public API contract becomes your DB schema. Rename a column and you break every client.
- Lazy loading — Jackson touches a lazy association after the session closed →
LazyInitializationException, or worse, it silently loads the whole graph. - Recursion — bidirectional relationships serialise infinitely.
Mapping options: MapStruct (compile-time generated, fast, type-safe — the standard) or hand-written mappers/static factory methods on records. Avoid ModelMapper-style runtime reflection mapping — it's slow and fails at runtime rather than compile time.
// entity stays internal
@Entity class User { Long id; String email; String passwordHash; boolean admin; }
// API contract — explicit, minimal
public record UserDto(Long id, String email) {
static UserDto from(User u) { return new UserDto(u.getId(), u.getEmail()); }
}
// MapStruct — generates the impl at compile time
@Mapper(componentModel = "spring")
public interface UserMapper {
UserDto toDto(User u);
@Mapping(target = "id", ignore = true)
@Mapping(target = "admin", ignore = true) // never bindable from a request
User toEntity(CreateUserRequest req);
}
How does JSON serialization work, and how do you customise it?
Boot auto-configures a Jackson ObjectMapper. @RequestBody/@ResponseBody use MappingJackson2HttpMessageConverter to convert between JSON and objects.
Customise at three levels:
- Properties —
spring.jackson.* for global defaults (date format, null inclusion, unknown-property handling). - Jackson2ObjectMapperBuilderCustomizer — the right bean to customise globally without discarding Boot's setup.
- Annotations —
@JsonProperty, @JsonIgnore, @JsonFormat, @JsonInclude, or a custom serializer.
Key point: defining your own ObjectMapper bean replaces Boot's entirely, silently losing its modules (JavaTimeModule, sensible defaults). Use the customizer instead.
// RIGHT — customise, don't replace
@Bean
Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> builder
.serializationInclusion(JsonInclude.Include.NON_NULL)
.failOnUnknownProperties(false)
.simpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
}
public record OrderDto(
@JsonProperty("order_id") Long id,
@JsonFormat(shape = STRING, pattern = "yyyy-MM-dd") LocalDate date,
@JsonIgnore String internalRef,
@JsonInclude(NON_NULL) String note) {}
spring:
jackson:
default-property-inclusion: non_null
deserialization:
fail-on-unknown-properties: false # be lenient on input
serialization:
write-dates-as-timestamps: false # ISO-8601, not epoch millis
What changed in Spring Boot 3, and what's involved in migrating?
The headline breaking change is Java EE → Jakarta EE: every javax.* package became jakarta.* (javax.persistence → jakarta.persistence, javax.validation → jakarta.validation). It's a package rename, so it's mechanical in your own code — the pain is third-party libraries that haven't published Jakarta-compatible versions.
Also: Java 17 minimum (21 recommended), Spring Framework 6, Hibernate 6, spring.factories auto-configuration registration replaced by AutoConfiguration.imports, Spring Cloud Sleuth replaced by Micrometer Tracing, trailing-slash matching removed, and GraalVM native image support becoming first-class.
Boot 3.x additions worth knowing: virtual threads (3.2), RestClient (3.2), @ServiceConnection for Testcontainers (3.1), ProblemDetail (3.0), structured logging (3.4).
// before (Boot 2) after (Boot 3)
import javax.persistence.Entity; import jakarta.persistence.Entity;
import javax.validation.Valid; import jakarta.validation.Valid;
import javax.servlet.Filter; import jakarta.servlet.Filter;
// auto-config registration moved:
// META-INF/spring.factories -> META-INF/spring/org.springframework.boot
// .autoconfigure.AutoConfiguration.imports
// automated migration:
// mvn org.openrewrite.maven:rewrite-maven-plugin:run \
// -Drewrite.activeRecipes=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_2
How do you improve Spring Boot startup time and memory footprint?
First, measure: run with --debug for the condition report, and use ApplicationStartup / BufferingApplicationStartup with /actuator/startup to see exactly where the time goes.
Then, in rough order of payoff:
- Exclude unused auto-configurations and narrow
scanBasePackages — less classpath scanning. - Trim dependencies — every starter adds auto-configurations to evaluate.
- Lazy initialisation (
spring.main.lazy-initialization=true) — beans created on first use. Great for dev, risky in prod (it defers failures and moves the cost to the first request). - CDS / AppCDS — share a class archive; Boot 3.3 makes this easy for a solid ~20–40% win with no code change.
- GraalVM native image — the nuclear option: ~50ms startup and a fraction of the memory.
@Bean
BufferingApplicationStartup applicationStartup() {
return new BufferingApplicationStartup(2048); // then GET /actuator/startup
}
// dev only — beans created on first use
spring.main.lazy-initialization=true
// trim what you don't use
@SpringBootApplication(exclude = {
SecurityAutoConfiguration.class,
DataSourceAutoConfiguration.class
})
// native image
// mvn -Pnative native:compile
// -> ~50ms startup, ~50MB RSS, no JVM needed
What is the IoC container? BeanFactory vs ApplicationContext.
Inversion of Control means the framework, not your code, creates objects and wires them together. The container reads bean definitions, resolves the dependency graph, and injects collaborators. DI is the mechanism; IoC is the principle.
- BeanFactory — the basic container: bean instantiation and wiring, lazy by default.
- ApplicationContext — a superset and what you always actually use: it adds event publishing, i18n, resource loading,
BeanPostProcessor/BeanFactoryPostProcessor auto-registration, annotation config, and AOP integration. It eagerly instantiates singletons at startup.
That eagerness is the point: configuration errors fail the deploy rather than the first request that touches a broken bean.
// Spring Boot gives you an ApplicationContext:
ConfigurableApplicationContext ctx = SpringApplication.run(App.class, args);
UserService svc = ctx.getBean(UserService.class);
// what IoC replaces:
class OrderService {
private final UserRepo repo = new JdbcUserRepo(new HikariDataSource(...));
// hardcoded, untestable, and every caller rebuilds the world
}
What causes a circular dependency and how do you fix it?
Bean A needs B and B needs A. With constructor injection it's genuinely impossible — neither can be built first — so Spring fails at startup with a clear cycle report. Since Boot 2.6 this is the default behaviour: circular references are prohibited unless you explicitly re-enable them.
The right fix is almost never a Spring trick — it's a design signal. A cycle means the responsibilities are wrong. Options, best first:
- Extract the shared logic into a third bean both depend on.
- Invert one direction with an event — A publishes, B listens; the compile-time edge disappears.
@Lazy on one injection point — a proxy defers the real resolution.spring.main.allow-circular-references=true — a temporary escape hatch, not a fix.
// the cycle
@Service class UserService { UserService(OrderService o) {} }
@Service class OrderService { OrderService(UserService u) {} }
// -> The dependencies of some of the beans form a cycle: userService -> orderService -> userService
// FIX 1 — extract the shared piece
@Service class NotificationService { } // no cycle
@Service class UserService { UserService(NotificationService n) {} }
@Service class OrderService { OrderService(NotificationService n, UserService u) {} }
// FIX 2 — invert with an event
@Service class OrderService {
void place(Order o) { events.publishEvent(new OrderPlacedEvent(o)); }
}
@Service class UserService {
@EventListener void onPlaced(OrderPlacedEvent e) { } // no reference back
}
How does the embedded server work, and what is a fat jar?
Instead of deploying a WAR into an installed Tomcat, Boot puts the servlet container inside your application as a library. SpringApplication.run starts it programmatically, so the app is a self-contained process: java -jar app.jar.
The fat (uber) jar packages your classes plus every dependency jar. Boot uses a nested-jar layout with its own JarLauncher as the manifest Main-Class, because standard Java can't load a jar-inside-a-jar.
Why it matters: the artifact is the deployable. No container to install, version, or configure; identical execution locally, in CI, and in prod. It's also what makes Boot fit containers so naturally — FROM eclipse-temurin:21-jre plus one jar.
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- mvn package -> app.jar with a nested BOOT-INF/lib/*.jar layout -->
<!-- app.jar
├── META-INF/MANIFEST.MF Main-Class: org.sf.boot.loader.JarLauncher
│ Start-Class: com.example.App
├── org/springframework/boot/loader/ (the launcher)
└── BOOT-INF/
├── classes/ (your code)
└── lib/ (every dependency jar, nested)
-->
How do you run code at startup?
Several hooks, each firing at a different point:
CommandLineRunner / ApplicationRunner — run after the context is fully initialised. The difference is only the argument type: raw String... vs parsed ApplicationArguments (which understands --key=value options).@EventListener(ApplicationReadyEvent.class) — after runners, when the app is genuinely ready to serve traffic.@PostConstruct — per-bean, right after that bean is initialised. Too early for anything needing proxies or other beans to be ready.
For anything that touches the database, calls a service, or needs @Transactional, use a runner or ApplicationReadyEvent — never @PostConstruct, where AOP proxies don't exist yet.
@Component
@Order(1) // runners execute in @Order sequence
public class DataSeeder implements ApplicationRunner {
private final UserRepo repo;
@Override
@Transactional // works here — context fully built
public void run(ApplicationArguments args) {
if (args.containsOption("seed")) { // --seed --count=10
repo.save(new User("admin@example.com"));
}
}
}
@EventListener(ApplicationReadyEvent.class) // last — server is accepting traffic
public void warmUp() { cache.preload(); }
How does logging work in Spring Boot?
Boot uses SLF4J as the API and Logback as the default implementation (via spring-boot-starter-logging, included in every starter). You code against SLF4J, so the backend is swappable — spring-boot-starter-log4j2 replaces it without touching code.
Configure levels per package in properties, or drop in logback-spring.xml for full control. Use logback-spring.xml rather than logback.xml — the Boot-prefixed name is loaded by Boot, so it can use <springProfile> and <springProperty>.
Two rules that matter more than config: always use parameterised messages ({}) not string concatenation, and never log secrets or PII.
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
// or @Slf4j with Lombok
log.debug("processing order {} for user {}", orderId, userId); // lazy — no cost when off
log.error("payment failed for order {}", orderId, exception); // exception LAST, no {}
// application.yml
logging:
level:
root: INFO
com.example.orders: DEBUG
org.hibernate.SQL: DEBUG # see generated SQL (dev only)
org.springframework.web: INFO
file:
name: /var/log/app.log
What does Spring Boot DevTools do?
Developer-experience tooling, active only at development time:
- Automatic restart — the context restarts when classpath files change (much faster than a manual restart thanks to a two-classloader trick).
- LiveReload — an embedded server that refreshes the browser automatically.
- Sensible dev defaults — disables template and static-resource caching, so a Thymeleaf edit shows up on refresh.
- Global settings in
~/.config/spring-boot/ shared across projects.
Crucially, it disables itself automatically when running from a fat jar (java -jar) or a fully-packaged app, and the Maven/Gradle plugin marks it non-transitive so it never leaks into downstream projects.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional> <!-- non-transitive -->
<scope>runtime</scope>
</dependency>
# tuning
spring:
devtools:
restart:
enabled: true
exclude: static/**,public/** # asset changes shouldn't restart
poll-interval: 2s
livereload:
enabled: true
How do you document a REST API?
springdoc-openapi — add the starter and it introspects your controllers, DTOs, and validation annotations to generate an OpenAPI 3 spec at /v3/api-docs, plus a Swagger UI at /swagger-ui.html. (SpringFox is dead — it never properly supported Boot 3.)
Most of it is inferred: return types become response schemas, @PathVariable/@RequestParam become parameters, and @NotNull/@Size become schema constraints. You add @Operation, @ApiResponse, and @Schema for the intent the code can't express — error cases, examples, descriptions.
The real value isn't the pretty UI: it's a machine-readable contract you can generate clients from, diff for breaking changes in CI, and import into Postman.
@RestController
@RequestMapping("/api/orders")
@Tag(name = "Orders", description = "Order management")
public class OrderController {
@Operation(summary = "Get an order by id")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Found"),
@ApiResponse(responseCode = "404", description = "Not found",
content = @Content(schema = @Schema(implementation = ProblemDetail.class)))
})
@GetMapping("/{id}")
public OrderDto get(@Parameter(description = "Order id") @PathVariable Long id) {
return service.find(id);
}
}
public record OrderDto(
@Schema(description = "Order id", example = "1042") Long id,
@Schema(description = "Total in GBP", example = "99.99") BigDecimal total) {}
How do you consume Kafka messages in Spring Boot, and how do you handle failures?
@KafkaListener on a method; Spring Kafka manages the consumer, polling, and offset commits. Publish with KafkaTemplate.
The critical concepts:
- Offsets — the consumer's position. Committing marks a message processed. Auto-commit is dangerous: it commits on a timer, so a crash mid-processing loses messages.
- Consumer groups — each partition goes to exactly one consumer in the group, so parallelism is capped by partition count.
- Ordering is only guaranteed within a partition — so messages that must stay ordered need the same key.
For failures: a DefaultErrorHandler with exponential backoff, and a dead letter topic for what won't ever succeed — otherwise a single poison message blocks its partition forever.
@Component
public class OrderListener {
@KafkaListener(topics = "orders", groupId = "billing", concurrency = "3")
public void handle(@Payload OrderEvent event,
@Header(KafkaHeaders.RECEIVED_KEY) String key,
Acknowledgment ack) {
if (processedRepo.exists(event.id())) { ack.acknowledge(); return; } // idempotent
service.process(event);
ack.acknowledge(); // commit only AFTER success
}
}
@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<String,Object> template) {
var recoverer = new DeadLetterPublishingRecoverer(template); // -> orders.DLT
var handler = new DefaultErrorHandler(recoverer,
new ExponentialBackOffWithMaxRetries(3));
handler.addNotRetryableExceptions(ValidationException.class); // never retry these
return handler;
}
How do you rate limit an API?
Rate limiting protects you from abuse, runaway clients, and accidental self-DoS by capping requests per client per window. Where to put it matters:
- Gateway/ingress (nginx, Spring Cloud Gateway, an API gateway) — best default. Bad traffic never reaches your app or costs you a thread.
- In-app (Resilience4j
RateLimiter, Bucket4j) — when limits are per-user/per-plan and need business context the gateway doesn't have.
The key design decision is local vs distributed: an in-memory limiter on 5 replicas means the real limit is 5× what you configured, and it resets on every deploy. A shared store (Redis) gives one true limit across the fleet.
Algorithm: the token bucket is the usual answer — it allows a controlled burst while enforcing a steady average rate.
// Bucket4j + Redis — one shared limit across all replicas
@Component
public class RateLimitFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws Exception {
String key = apiKeyOf(req);
Bucket bucket = buckets.resolve(key); // proxied to Redis
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
if (probe.isConsumed()) {
res.setHeader("X-RateLimit-Remaining", String.valueOf(probe.getRemainingTokens()));
chain.doFilter(req, res);
} else {
long waitSec = probe.getNanosToWaitForRefill() / 1_000_000_000;
res.setHeader("Retry-After", String.valueOf(waitSec)); // tell them when
res.sendError(429, "Too Many Requests");
}
}
}
How do you manage configuration and secrets across many services?
Options, in rough order of modernity:
- Spring Cloud Config Server — a central service serving config from a Git repo. You get versioning, audit, and per-profile/per-service resolution, plus refresh without redeploy.
- Kubernetes ConfigMaps and Secrets — mounted as env vars or files. No extra service to run; the platform does it.
- A dedicated secrets manager — Vault, AWS Secrets Manager. This is the right answer for secrets: encryption at rest, access policies, audit logs, and rotation.
The non-negotiable part: secrets never go in Git, not even in a private repo — and a Kubernetes Secret is only base64, not encryption, so it needs encryption-at-rest and RBAC to mean anything.
# client — where to find config
spring:
application:
name: order-service # -> resolves order-service-prod.yml on the server
config:
import: "configserver:http://config-server:8888"
cloud:
vault:
uri: https://vault:8200
authentication: KUBERNETES
# refresh without redeploy
@RefreshScope # bean is recreated on POST /actuator/refresh
@Component
class FeatureFlags {
@Value("${app.feature.new-checkout:false}") boolean newCheckout;
}
How do services find each other, and what does an API gateway do?
Service discovery solves "where is the order service?" when instances come and go with autoscaling. Options: a registry like Eureka/Consul (services register on startup, clients look up and load balance client-side), or — on Kubernetes — DNS-based discovery built into the platform: http://order-service resolves via the Service, which load balances for you. No Eureka needed.
An API gateway (Spring Cloud Gateway) is the single entry point in front of your services. It handles the concerns you don't want duplicated in every service: routing, authentication, rate limiting, TLS termination, CORS, request aggregation, and canary routing.
The modern reality: on Kubernetes, the platform provides most of this — Services for discovery, Ingress for the gateway. Eureka and Ribbon are largely legacy.
# Spring Cloud Gateway — routing + cross-cutting concerns
spring:
cloud:
gateway:
routes:
- id: orders
uri: lb://order-service # lb:// = load-balanced via discovery
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1
- name: CircuitBreaker
args: { name: ordersCb, fallbackUri: forward:/fallback }
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 100
redis-rate-limiter.burstCapacity: 200
# on Kubernetes, discovery is just DNS:
# http://order-service.default.svc.cluster.local -> the Service load balances
How do you test code that calls an external HTTP service?
Three levels, and you want a mix:
- Unit — mock your own client interface. Fast, but proves nothing about the actual HTTP/JSON.
- WireMock — a real HTTP server returning stubbed responses. Exercises the real client, serialisation, headers, timeouts, and error handling. This is the sweet spot for testing your integration code.
- Contract tests (Spring Cloud Contract / Pact) — the only thing that catches the provider changing its API.
The gap WireMock leaves is important: your stub encodes your belief about the provider's responses. If the provider changes, your tests still pass and production breaks. That's what contract testing exists to solve.
@SpringBootTest
@WireMockTest(httpPort = 8089)
class PaymentClientTest {
@Autowired PaymentClient client;
@Test
void mapsGatewayErrorToDomainException() {
stubFor(post("/charge")
.willReturn(aResponse().withStatus(402)
.withHeader("Content-Type", "application/json")
.withBody("{\"error\":\"insufficient_funds\"}")));
assertThatThrownBy(() -> client.charge(req))
.isInstanceOf(InsufficientFundsException.class);
}
@Test
void timesOutRatherThanHanging() {
stubFor(post("/charge").willReturn(aResponse().withFixedDelay(10_000)));
assertThatThrownBy(() -> client.charge(req))
.isInstanceOf(ResourceAccessException.class); // proves the timeout works
}
}
How do you version a REST API?
Main strategies:
- URI path —
/api/v1/orders. Ugly to purists (the resource didn't change, its representation did), but overwhelmingly the most common: obvious, cacheable, browsable, trivial to route at a gateway. - Header / content negotiation —
Accept: application/vnd.acme.v2+json. Theoretically cleanest — the URI stays a stable resource identifier — but invisible, hard to test in a browser, and easy for caches and proxies to get wrong. - Query param —
?version=2. Simple; muddles caching and filtering.
But the more important answer: the best versioning is not needing to version. Most changes can be made backward compatible — add fields, never remove or repurpose them, and never change a field's type or meaning. Version only on a genuine breaking change.
@RestController
@RequestMapping("/api/v1/orders")
class OrderControllerV1 {
@GetMapping("/{id}") OrderV1Dto get(@PathVariable Long id) {
return mapper.toV1(service.find(id)); // both delegate to ONE service
}
}
@RestController
@RequestMapping("/api/v2/orders")
class OrderControllerV2 {
@GetMapping("/{id}") OrderV2Dto get(@PathVariable Long id) {
return mapper.toV2(service.find(id)); // versioning lives in the mapping layer
}
}
// header-based alternative
@GetMapping(value = "/{id}", produces = "application/vnd.acme.v2+json")
How do you configure multiple datasources?
Once there's more than one DataSource, Boot's auto-configuration backs off — it can't guess which is which. You define them manually:
- A
DataSource bean per database, one marked @Primary. - A separate
EntityManagerFactory and TransactionManager per datasource. @EnableJpaRepositories per datasource, each scoped to its own package of repositories and entities.
The package separation is what makes it work: each repository package binds to exactly one entity manager.
The big caveat: a transaction spans one datasource only. Writing to both in one @Transactional gives you two independent transactions — one can commit while the other rolls back.
@Configuration
@EnableJpaRepositories(
basePackages = "com.example.orders.repo", // package-scoped!
entityManagerFactoryRef = "ordersEmf",
transactionManagerRef = "ordersTx")
public class OrdersDbConfig {
@Bean @Primary
@ConfigurationProperties("app.datasource.orders")
DataSource ordersDataSource() { return DataSourceBuilder.create().build(); }
@Bean @Primary
LocalContainerEntityManagerFactoryBean ordersEmf(EntityManagerFactoryBuilder b) {
return b.dataSource(ordersDataSource())
.packages("com.example.orders.domain")
.persistenceUnit("orders").build();
}
@Bean @Primary
PlatformTransactionManager ordersTx(@Qualifier("ordersEmf") EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
}
// ...and a mirrored config for the second DB, without @Primary
How do you handle file uploads safely?
MultipartFile on a @PostMapping(consumes = MULTIPART_FORM_DATA_VALUE). Boot auto-configures multipart resolution; set limits with spring.servlet.multipart.max-file-size and max-request-size.
The interesting part is the security, because upload is one of the most attacked endpoints:
- Never trust the filename —
getOriginalFilename() is attacker-controlled and can contain ../../ (path traversal). Generate your own name. - Never trust the content type — the client sets it. Sniff the real type from magic bytes.
- Cap the size, or one request fills the disk or heap.
- Store outside the webroot — ideally object storage (S3), never a directory the server will execute from.
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public FileDto upload(@RequestParam("file") MultipartFile file) throws IOException {
if (file.isEmpty()) throw new BadRequestException("empty file");
if (file.getSize() > 5_000_000) throw new BadRequestException("too large");
String mime = tika.detect(file.getInputStream()); // magic bytes, not the header
if (!ALLOWED.contains(mime)) throw new BadRequestException("type not allowed: " + mime);
String ext = ALLOWED_EXT.get(mime);
String stored = UUID.randomUUID() + "." + ext; // IGNORE their filename
s3.putObject(bucket, stored, file.getInputStream()); // outside the webroot
return new FileDto(stored, file.getSize(), mime);
}
spring:
servlet:
multipart:
max-file-size: 5MB
max-request-size: 10MB
How do you handle sessions when running multiple instances?
The problem: an HTTP session lives in one JVM's memory. Add a second instance and a user's next request may land on a pod that's never heard of them — they're logged out at random. Restarts and rolling deploys log everyone out too.
Options:
- Stateless (JWT/token) — no session at all. Scales trivially; the revocation trade-off applies.
- Spring Session + Redis — sessions move to a shared store; every instance sees them, and they survive restarts. Usually the best of both: real revocable sessions and horizontal scaling. It's one dependency and one property.
- Sticky sessions — the load balancer pins a user to one instance. Works, but that instance dying loses their session, it defeats even load distribution, and it makes rolling deploys disruptive.
<dependency>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
spring:
session:
store-type: redis
timeout: 30m
data:
redis:
host: redis
// that's it — HttpSession now transparently reads/writes Redis.
// Existing code is unchanged:
@GetMapping("/cart")
public Cart cart(HttpSession session) {
return (Cart) session.getAttribute("cart"); // now shared across all pods
}
How do you register a bean conditionally?
The @Conditional* family — the same mechanism Boot's own auto-configuration uses:
@ConditionalOnProperty — the workhorse: enable a bean from config, with matchIfMissing deciding opt-in vs opt-out.@ConditionalOnClass / @ConditionalOnMissingClass — classpath-driven.@ConditionalOnBean / @ConditionalOnMissingBean — context-driven; the back-off mechanism.@ConditionalOnWebApplication, @ConditionalOnExpression, or a custom Condition.@Profile — really a specialised @Conditional on active profiles.
Important limitation: these are evaluated once, at startup. They decide whether a bean exists — they are not runtime feature flags.
@Bean
@ConditionalOnProperty(prefix = "app.audit", name = "enabled",
havingValue = "true", matchIfMissing = false) // opt-in
AuditService auditService() { return new RealAuditService(); }
@Bean
@ConditionalOnMissingBean(AuditService.class) // fallback when the above is off
AuditService noopAudit() { return new NoopAuditService(); }
@Bean
@ConditionalOnBean(DataSource.class) // ordering matters here!
HealthIndicator dbHealth(DataSource ds) { return new DataSourceHealthIndicator(ds); }
How do you control the HTTP status and headers of a response?
Three ways, in increasing explicitness:
- Return the object — 200 OK automatically. Cleanest when the status never varies.
@ResponseStatus(HttpStatus.CREATED) — a fixed non-200 status on the method (or on an exception class).ResponseEntity<T> — full control over status, headers, and body. Use it when the status varies or you must set headers like Location.
Status codes carry real meaning — clients, proxies, caches, and monitoring all act on them. Returning 200 OK with {"success": false} in the body is the anti-pattern: it throws away everything HTTP gives you for free.
// 1. plain object -> 200
@GetMapping("/{id}")
UserDto get(@PathVariable Long id) { return service.find(id); }
// 2. fixed status
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
UserDto create(@RequestBody @Valid CreateUserRequest r) { return service.create(r); }
// 3. full control — status varies + Location header
@PostMapping
ResponseEntity<UserDto> create(@RequestBody @Valid CreateUserRequest r) {
UserDto saved = service.create(r);
return ResponseEntity
.created(URI.create("/api/users/" + saved.id())) // 201 + Location
.body(saved);
}
// exceptions carry status too
@ResponseStatus(HttpStatus.NOT_FOUND)
public class NotFoundException extends RuntimeException { }
What happens when a prototype bean is injected into a singleton?
You get one frozen prototype instance — injection happens once, at singleton creation, so the "prototype" behaves like a singleton for the rest of the app's life. The container never re-injects.
Fixes:
ObjectProvider<Proto> / Provider<Proto> — inject a factory and call getObject() per use.@Scope(value = "prototype", proxyMode = TARGET_CLASS) — inject a scoped proxy that resolves a fresh instance per method call.@Lookup on an abstract method — Spring overrides it to fetch a new bean.
@Component
public class ReportService {
private final ObjectProvider<ReportBuilder> builders;
ReportService(ObjectProvider<ReportBuilder> builders) { this.builders = builders; }
public Report build() {
return builders.getObject().generate(); // fresh prototype per call
}
}
@Bean vs @Component? And what does proxyBeanMethods do in @Configuration?
@Component — class-level; the component scan finds your class and instantiates it. @Bean — method-level inside a configuration class; you write the factory code, so it's the only way to register third-party classes you can't annotate (DataSource, ObjectMapper, RestClient) or beans needing build logic.
@Configuration(proxyBeanMethods = true) (default) CGLIB-proxies the config class so @Bean methods calling each other still return the same singleton. Set it to false for faster startup when your @Bean methods don't cross-call — all of Spring Boot's own auto-configurations do this.
@Configuration(proxyBeanMethods = false)
public class HttpConfig {
@Bean RestClient restClient(RestClient.Builder b) { // third-party type
return b.baseUrl("https://api.example.com").build();
}
}
@RestController vs @Controller?
@RestController = @Controller + @ResponseBody on every handler: return values are serialized straight into the response body (JSON via Jackson) — no view resolution.
@Controller alone is MVC-style: a returned String is a view name resolved to a template (Thymeleaf/JSP). To return data from it you'd annotate individual methods with @ResponseBody.
@RestController // JSON API
@RequestMapping("/api/users")
class UserApi {
@GetMapping List<UserDto> all() { return service.all(); } // -> JSON array
}
@Controller // server-rendered pages
class UserPages {
@GetMapping("/users") String page(Model m) { return "users"; } // -> users.html
}
What is REST? What are its constraints?
REST (Representational State Transfer) is an architectural style for APIs where resources (nouns, identified by URIs) are manipulated through uniform HTTP methods, exchanging representations (usually JSON).
The six constraints:
- Client–server — separated concerns.
- Stateless — every request carries all context; no server session.
- Cacheable — responses declare cacheability.
- Uniform interface — resource URIs + standard methods + self-descriptive messages (+ HATEOAS in the full definition).
- Layered system — clients can't tell if they hit a proxy, gateway or origin.
- Code on demand (optional).
GET /orders # list (collection resource)
POST /orders # create
GET /orders/42 # read one
PUT /orders/42 # replace
DELETE /orders/42 # delete
# verbs live in the METHOD, never the path (/getOrders is RPC, not REST)
GET/POST/PUT/PATCH/DELETE — and PUT vs PATCH vs POST?
- GET — read; safe (no side effects) and idempotent.
- POST — create / non-idempotent action; two identical POSTs = two resources.
- PUT — full replace at a known URI; idempotent — repeating it yields the same final state.
- PATCH — partial update (only the fields sent); not guaranteed idempotent by spec, though field-set patches usually are.
- DELETE — remove; idempotent (second call: state unchanged, often 404).
PUT vs POST: PUT when the client knows the resource URI (PUT /users/42); POST to a collection when the server assigns identity (POST /users → 201 + Location).
PUT /users/42
{ "name": "Chinmaya", "email": "c@x.com" } # full document
PATCH /users/42
{ "email": "new@x.com" } # only what changes
Which HTTP status code do you return when? (incl. 401 vs 403, 502 vs 503)
2xx: 200 OK; 201 Created (+Location); 204 No Content (successful DELETE/PUT with no body).
4xx (client's fault): 400 malformed/invalid request; 401 Unauthorized = not authenticated (missing/expired credentials); 403 Forbidden = authenticated but not allowed; 404 not found; 405 method not allowed; 409 conflict (duplicate, version clash); 415 unsupported media type; 429 too many requests.
5xx (server's fault): 500 unhandled error; 502 Bad Gateway = upstream returned garbage/refused; 503 Service Unavailable = server itself overloaded/down for maintenance; 504 upstream timeout.
// Spring: pick codes deliberately
@PostMapping ResponseEntity<UserDto> create(@Valid @RequestBody UserReq r) {
UserDto u = service.create(r); // duplicate -> throw -> 409
return ResponseEntity.created(URI.create("/users/" + u.id())).body(u);
}
What is content negotiation? What do produces and consumes do?
Client and server agreeing on a representation format: the client states what it can send (Content-Type header) and what it wants back (Accept header); the server picks a matching HttpMessageConverter.
consumes = "application/json" — this handler only accepts that request Content-Type; mismatch → 415 Unsupported Media Type.produces = "application/json" — only serves clients whose Accept matches; mismatch → 406 Not Acceptable.
@PostMapping(value = "/report",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<byte[]> report(@RequestBody ReportReq r) { ... }
What is HATEOAS?
Hypermedia As The Engine Of Application State — responses embed links to the actions available next, so clients navigate the API like a website instead of hard-coding URL patterns and business rules.
An order response might carry self, cancel, and payment links — and simply omit cancel once shipped, so the client never re-implements "when is cancelling allowed".
It's the Richardson maturity level 3 that most real APIs skip; Spring supports it via spring-boot-starter-hateoas (EntityModel, linkTo(methodOn(...))).
{
"id": 42, "status": "PLACED", "total": 1200,
"_links": {
"self": { "href": "/orders/42" },
"cancel": { "href": "/orders/42/cancellation" },
"payment": { "href": "/orders/42/payment" }
}
}
What are transaction isolation levels, and how do you set them in Spring?
Isolation controls what concurrent transactions can see of each other, trading correctness against throughput:
- READ_UNCOMMITTED — can read uncommitted changes (dirty reads).
- READ_COMMITTED — only committed data; but re-reading a row may give a new value (non-repeatable read). Default in PostgreSQL/Oracle.
- REPEATABLE_READ — re-reads are stable; new matching rows may still appear (phantom reads). Default in MySQL/InnoDB.
- SERIALIZABLE — as if transactions ran one at a time; slowest, may abort with serialization errors.
Spring: @Transactional(isolation = Isolation.REPEATABLE_READ) — the default Isolation.DEFAULT just uses the database's setting.
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transfer(long from, long to, BigDecimal amt) {
// balance check + debit + credit must see a frozen world
}
Authentication vs authorization?
Authentication (authn) — proving who you are: credentials, JWT validation, OAuth login. Fails with 401.
Authorization (authz) — deciding what you may do, evaluated after identity is known: roles, permissions, resource ownership. Fails with 403.
In Spring Security: authentication happens in the filter chain (e.g. a JWT filter builds the Authentication in the SecurityContext); authorization happens in authorizeHttpRequests rules and @PreAuthorize checks against it.
http.authorizeHttpRequests(a -> a
.requestMatchers("/admin/**").hasRole("ADMIN") // authorization rule
.anyRequest().authenticated()) // authentication required
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); // authn
SQL injection, XSS, CSRF — what are they and how do you prevent each?
- SQL injection — user input concatenated into SQL becomes SQL. Prevent: parameterized queries always — JPA/JPQL named params, PreparedStatement; never string-build queries.
- XSS — attacker-supplied script executes in victims' browsers (stored/reflected/DOM). Prevent: output encoding by default (Angular interpolation auto-escapes),
Content-Security-Policy, sanitize rich HTML input, HttpOnly cookies so scripts can't read tokens. - CSRF — victim's browser auto-sends cookies with a forged cross-site request. Prevent: CSRF tokens for cookie/session auth,
SameSite cookies; pure Bearer-token APIs are inherently exempt (the browser doesn't attach the token automatically).
// SQLi-proof by construction:
@Query("select u from User u where u.email = :email")
Optional<User> byEmail(@Param("email") String email);
// vs the vulnerable anti-pattern:
// "select * from users where email = '" + email + "'" // NEVER
What does @Entity require? Why does an entity need a no-arg constructor?
An entity needs: @Entity, an @Id, a no-arg constructor (at least protected), and it must not be final (Hibernate subclasses it for lazy proxies).
The no-arg constructor exists because Hibernate materializes rows reflectively: it instantiates the empty object first, then populates fields — it can't guess how to call your custom constructor.
Also expected: equals/hashCode based on a stable key (not auto-generated id before persist), and no required-args-only records (records can't be entities — no no-arg constructor, final fields).
@Entity
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
protected Order() {} // for Hibernate only
public Order(Customer c) { ... } // for your code
}
Entity lifecycle states — transient, managed, detached, removed? And find() vs getReference()?
- Transient (new) — plain object, unknown to JPA, no row.
- Managed (persistent) — attached to the persistence context; dirty checking auto-flushes changes, no save() call needed.
- Detached — was managed, context closed/cleared; changes are ignored until re-attached with
merge(). - Removed — marked for DELETE at flush.
find(id) hits the DB (or L1 cache) immediately and returns null if absent. getReference(id) returns a lazy proxy without any query — perfect for setting FK associations; throws EntityNotFoundException on first real access if the row doesn't exist.
// no SELECT on customer — proxy carries only the id
Order o = new Order();
o.setCustomer(em.getReference(Customer.class, custId));
em.persist(o);
First-level vs second-level cache in Hibernate?
L1 (persistence context) — per EntityManager/transaction, always on, cannot be disabled. Repeated find() for the same id in one transaction hits memory, and it guarantees identity: same id → same object instance.
L2 — shared across sessions at the SessionFactory level, off by default; opt-in per entity with @Cacheable/@Cache plus a provider (Ehcache, Infinispan, Redis via Redisson). Survives transactions; needs an invalidation strategy and care in clusters.
There's also the query cache (caches query result ids, entities resolved via L2) — separate switch, frequently more trouble than value.
@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Country { ... } // read-mostly reference data: ideal L2 citizen
@OneToMany, @ManyToOne, @ManyToMany — owning side and mappedBy?
The owning side is the side whose table holds the foreign key — only its changes are written to the DB. In a bidirectional mapping the other side declares mappedBy = "fieldOnOwner" and is a read-only mirror.
@ManyToOne — always the owning side (its row has the FK). Default fetch EAGER (change it!).@OneToMany(mappedBy=...) — inverse side; default LAZY. Without mappedBy you get a join table or FK-update-by-parent — avoid.@ManyToMany — join table; one side picks @JoinTable, the other mappedBy.@OneToOne — FK side owns; lazy on the non-owning side often doesn't work without bytecode tricks.
@Entity class Order {
@ManyToOne(fetch = FetchType.LAZY) // owning side: orders.customer_id
@JoinColumn(name = "customer_id")
private Customer customer;
}
@Entity class Customer {
@OneToMany(mappedBy = "customer") // mirror; changes here are ignored
private List<Order> orders = new ArrayList<>();
}
Cascade types? And orphanRemoval vs CascadeType.REMOVE?
Cascading propagates an EntityManager operation from parent to children: PERSIST, MERGE, REMOVE, REFRESH, DETACH, ALL. Typical: cascade = {PERSIST, MERGE} on a parent→child collection so saving an Order also saves its lines.
CascadeType.REMOVE: deleting the parent deletes children too.
orphanRemoval = true: additionally, disconnecting a child (removing it from the collection / nulling the reference) deletes it — the child has no life without its parent.
@OneToMany(mappedBy = "order",
cascade = CascadeType.ALL,
orphanRemoval = true) // composition: lines die with/without the order
private List<OrderLine> lines = new ArrayList<>();
ID generation strategies — IDENTITY vs SEQUENCE vs AUTO vs TABLE?
- IDENTITY — DB auto-increment column. Simple, but the id only exists after the INSERT, so Hibernate must insert immediately — disables JDBC batching of inserts. The MySQL default choice.
- SEQUENCE — DB sequence fetched before insert (with allocationSize batching sequence calls). Ids known early, insert batching works. Preferred on PostgreSQL/Oracle.
- AUTO — provider picks per dialect (Postgres→sequence, MySQL→identity in practice); explicit is better.
- TABLE — simulates a sequence via a table with row locking; portable but slow, effectively legacy.
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
@SequenceGenerator(name = "order_seq", sequenceName = "order_seq",
allocationSize = 50) // 1 DB round-trip per 50 ids
private Long id;
How do you map inheritance in JPA — SINGLE_TABLE vs JOINED vs TABLE_PER_CLASS?
- SINGLE_TABLE (default) — one table for the whole hierarchy + a discriminator column. Fastest (no joins), but subclass columns must all be nullable and the table gets wide.
- JOINED — one table per class, subclass tables share the parent's PK. Normalized, constraints work; polymorphic reads need joins.
- TABLE_PER_CLASS — each concrete class gets a complete table; polymorphic queries become UNIONs, identity needs sequence-style generators. Rarely a good idea.
Alternative worth naming: @MappedSuperclass — inherit fields (audit columns) with no polymorphic querying at all.
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "payment_type")
abstract class Payment { @Id Long id; BigDecimal amount; }
@Entity @DiscriminatorValue("CARD")
class CardPayment extends Payment { String last4; } // nullable in the table
What is the open-session-in-view anti-pattern?
OSIV keeps the Hibernate session (and its DB connection lease) open for the entire HTTP request — including view rendering / JSON serialization — so lazy loading "just works" in controllers. Spring Boot enables it by default (spring.jpa.open-in-view=true, with a startup warning).
Why it's an anti-pattern: it hides N+1 queries triggered from serialization, holds connections far longer than the transaction needs (pool exhaustion under load), and blurs the transaction boundary — queries run after the service layer returned.
Fix: open-in-view=false and fetch what the response needs explicitly (fetch joins, @EntityGraph, DTO projections).
# application.yml
spring:
jpa:
open-in-view: false # surface lazy-loading issues at dev time
What are OAuth2 and OIDC? Explain the authorization code flow.
OAuth2 is a delegated authorization framework: a user lets a client app access resources on their behalf without sharing passwords — the client gets a scoped access token. OIDC is an identity layer on top: adds an ID token (a JWT about who logged in) — OAuth2 = "can this app read my calendar", OIDC = "log in with Google".
Authorization code flow: client redirects the browser to the authorization server → user authenticates and consents → auth server redirects back with a short-lived code → the client's backend exchanges code + client secret (+ PKCE verifier) for tokens. Tokens never transit the browser URL.
# 1. Front channel
GET https://auth.example.com/authorize?response_type=code
&client_id=app&redirect_uri=https://app/cb&scope=openid profile&state=xyz
&code_challenge=BASE64(SHA256(verifier))&code_challenge_method=S256
# 2. Callback: https://app/cb?code=SplxlO...&state=xyz
# 3. Back channel
POST /token grant_type=authorization_code&code=SplxlO...&code_verifier=verifier
What happens in the HTTPS/TLS handshake (high level)?
Goal: authenticate the server and agree on symmetric keys. TLS 1.3, one round trip:
- ClientHello — supported versions/ciphers + the client's ephemeral key share.
- ServerHello — chosen cipher + server key share; then the certificate and a signed proof it holds the private key.
- Client validates the certificate chain to a trusted root CA and checks the hostname.
- Both derive the same session keys via ephemeral Diffie-Hellman (ECDHE) — the key never travels; encrypted application data begins.
Asymmetric crypto bootstraps trust; the actual traffic uses fast symmetric encryption (AES-GCM).
How do you handle distributed transactions? Saga pattern — choreography vs orchestration? Why not 2PC?
Across microservices there's no shared transaction — each service commits locally. A saga is a sequence of local transactions where each step publishes an event/command for the next, and failures trigger compensating transactions that undo prior steps (cancel reservation, refund payment).
- Choreography — no central brain; services react to each other's events. Simple to start, but the flow lives nowhere — hard to see and evolve.
- Orchestration — a saga orchestrator tells each participant what to do and tracks state. Explicit flow, central place for timeouts/compensation; the orchestrator is one more thing to run.
2PC (XA) is avoided: a synchronous blocking protocol where the slowest/failed participant locks everyone; coordinators are a SPOF and modern brokers/NoSQL don't speak it.
// Orchestration sketch
OrderSaga: create PENDING order
-> cmd: reserve inventory -> ok
-> cmd: charge payment -> FAILS
-> compensate: release inventory
-> mark order REJECTED // eventual consistency, never half-charged
Database-per-service — why, and how do you query across services?
Each microservice owns its schema; no other service touches its tables. That's what makes services independently deployable (schema changes don't ripple), lets each pick the right store, and keeps coupling at the API level — a shared database is a monolith with extra network hops.
Cross-service reads, in preference order:
- API composition — the caller (or a BFF/gateway) calls both services and joins in memory. Fine for small fan-outs.
- CQRS read model / data replication — services publish events; a consumer maintains a denormalized local view (e.g. order-history service listening to order + customer events). Joins become local queries; cost is eventual consistency.
-- Anti-pattern: order-service SQL joining customer-service tables
-- Instead: order-service keeps its own slim copy, updated by events
CREATE TABLE customer_snapshot (
customer_id BIGINT PRIMARY KEY,
display_name TEXT, -- only the fields orders actually need
updated_at TIMESTAMPTZ
);
What are CQRS and event sourcing?
CQRS — Command Query Responsibility Segregation: separate the write model (commands, invariants, normalized) from the read model (queries, denormalized views built for each screen). They can be different classes, different tables, or different databases synced by events.
Event sourcing — instead of storing current state, store the append-only sequence of events (OrderPlaced, ItemAdded, OrderCancelled); state is rebuilt by replaying them. You gain a complete audit trail, temporal queries ("state as of Tuesday"), and natural event publication — at the cost of significant complexity (versioning events, snapshots, eventual-consistent reads).
They pair naturally (events feed the read models) but are independent — CQRS alone is common; event sourcing is niche.
// CQRS-lite in a normal Spring app — no framework needed
// write side
public void placeOrder(PlaceOrderCmd cmd) { ... } // entities, invariants
// read side
public Page<OrderSummaryView> search(OrderFilter f) { ... } // projection query,
// maybe a materialized view
What are the 12-factor app principles (the ones that matter most)?
A methodology for cloud-ready apps. The ones interviewers expect with examples:
- Config in the environment — same artifact everywhere; env vars/config server, never per-env builds.
- Stateless processes — no local session/files; state in DB/Redis → any instance can die or scale.
- Backing services as attached resources — DB/broker are swappable URLs.
- Build, release, run separation — one immutable image promoted through envs.
- Logs as event streams — write to stdout; the platform ships them (ELK/OpenSearch).
- Disposability — fast startup, graceful shutdown; enables rolling deploys/autoscaling.
- Dev/prod parity — same DB/broker locally (Docker/Testcontainers).
# one image, env-driven config (factor III)
SPRING_DATASOURCE_URL=jdbc:postgresql://prod-db:5432/app
SPRING_PROFILES_ACTIVE=prod
server.shutdown=graceful # factor IX: disposability
How big should a microservice be? How do you decide boundaries (DDD, bounded context)?
Size is the wrong metric — the unit is the bounded context (DDD): a boundary within which a model and its language are consistent. "Product" means different things to Catalog (descriptions, images), Inventory (stock, locations) and Pricing (rules, discounts) — three contexts, three candidate services, each owning its own data.
Good boundary tests:
- Changes for one business capability stay in one service (high cohesion).
- It can deploy alone; no synchronous call chains for its core flow.
- No shared database and no chatty two-way sync with a sibling — if two services always change together, merge them.