Inversion of Control & Dependency Injection โ The Idea Spring is Built On
Every Spring interview opens here. The trick is not to recite "IoC means inversion of control" โ interviewers have heard that line a thousand times. They want to see if you understand what got inverted and why it matters in real code.
new RazorpayClient() inside CheckoutService. A week later, his team switches to Stripe. Now every place that new'd a Razorpay client has to change. Multiply that across 80 services and you'll never sleep again. This is the pain Spring's IoC was designed to remove.The "inversion" in plain English
Without IoC, your class controls its dependencies โ it new's them up directly. With IoC, that control is inverted: a container creates the dependencies and hands them to your class. Your class just declares "I need a PaymentGateway" โ it doesn't pick which implementation, doesn't construct it, doesn't manage its lifecycle.
Dependency Injection is one specific technique to achieve IoC โ handing dependencies in via the constructor, a setter, or a field, rather than letting the class build them itself.
class CheckoutService { private RazorpayClient rp = new RazorpayClient(); // hard-coded public void pay(Order o) { rp.charge(o.amount); } }
@Service class CheckoutService { private final PaymentGateway gw; public CheckoutService(PaymentGateway gw) { // Spring will inject this.gw = gw; } public void pay(Order o) { gw.charge(o.amount); } }
Why this matters beyond aesthetics
- Testability. In a unit test, hand in a
FakePaymentGatewayinstead of a real one. No mocking framework needed. - Swappability. Switch from Razorpay to Stripe by changing a configuration line, not 200 source files.
- Lifecycle management. The container decides when to create, share, and destroy beans โ singleton vs request-scoped, eager vs lazy, etc.
- Cross-cutting concerns. Because beans go through the container, Spring can wrap them with proxies for transactions, security, caching, async โ without your code knowing.
ApplicationContext vs BeanFactory โ Two Containers, One Lineage
The two interfaces
- BeanFactory โ the original, low-level container. Lazy by default (creates beans only when first asked). Knows about beans and their wiring, nothing more.
- ApplicationContext โ the grown-up superset. Extends BeanFactory and adds: event publishing, internationalization, environment abstraction, AOP integration, eager singleton instantiation by default, and resource loading.
Common ApplicationContext implementations
| Class | Used for |
|---|---|
AnnotationConfigApplicationContext | Java config + annotations (modern default) |
ClassPathXmlApplicationContext | Classic XML config (legacy) |
AnnotationConfigServletWebServerApplicationContext | What Spring Boot uses for embedded Tomcat |
ReactiveWebServerApplicationContext | WebFlux apps |
Bean Scopes โ One Bean, Many Lifetimes
UserContext bean to hold "the currently logged-in user." She marks it @Component. In production, two users start seeing each other's data. Why? She accidentally left the bean as a singleton โ one shared instance across every request and every thread.The five built-in scopes
| Scope | Lifetime | When to use |
|---|---|---|
singleton (default) | One per Spring container | Stateless services, repositories, controllers, gateways |
prototype | New instance every getBean() | Stateful objects you build per-task (rare) |
request | One per HTTP request | Per-request data (request id, user context) |
session | One per HTTP session | Shopping cart, session preferences |
application | One per ServletContext | Rare โ mostly equivalent to singleton in modern apps |
@Component @Scope("prototype") class ReportBuilder { /* fresh instance per request */ } @Component @RequestScope class UserContext { private String userId; }
ObjectProvider<ReportBuilder> or Provider<ReportBuilder> and call .getObject() each time, or annotate the prototype with @Scope(value="prototype", proxyMode=ScopedProxyMode.TARGET_CLASS).Bean Lifecycle โ From Class on Disk to Object on the Heap
The full sequence (singleton scope)
- Class detection. Component scan finds
@Component,@Service,@Configuration, etc., or you declare a@Beanmethod. - Bean definition registered. Spring stores metadata (class, scope, dependencies) in a
BeanDefinition. - Instantiation. Spring calls the constructor, passing in any constructor-injected dependencies.
- Property population. Setters and field injection happen.
- Aware callbacks. If the bean implements
BeanNameAware,ApplicationContextAware, etc., those are called. BeanPostProcessor.postProcessBeforeInitialization()โ registered post-processors get a shot. AOP proxy creation happens here.- Initialization.
@PostConstruct, thenInitializingBean.afterPropertiesSet(), then any custominit-method. BeanPostProcessor.postProcessAfterInitialization()โ final wrapping.- Bean in use. Application code injects/uses the bean.
- Container shutdown.
@PreDestroy, thenDisposableBean.destroy(), then any customdestroy-method.
@Component class ConnectionPool { @PostConstruct public void warmUp() { // open connections, prefetch metadata, etc. } @PreDestroy public void drain() { // close connections cleanly } }
@PostConstruct), and you start working. On your last day, you do an exit interview (@PreDestroy) and hand back the laptop.@PostConstruct won't be called if the bean is wrapped by an AOP proxy in a way that breaks reflection on the original class โ and starting from Java 9, @PostConstruct moved out of the JDK into jakarta.annotation. Spring Boot 3 uses jakarta.*; older 2.x uses javax.*. Importing the wrong one means the annotation silently does nothing.@Component, @Service, @Repository, @Controller โ Same Family, Different Roles
Functionally? Mostly the same. Semantically? Different roles.
All four register the class as a Spring bean. The differences are intent signaling + a few specific behaviors:
@Componentโ generic Spring-managed bean. The base annotation; the others are specializations.@Serviceโ marker for the service layer (business logic). Same as@Componentat runtime, but tells readers "this is where the logic lives."@Repositoryโ marker for the persistence layer. Spring auto-translates JDBC/JPA exceptions intoDataAccessExceptionโ that's a real behavioral difference.@Controllerโ marker for an MVC controller. Spring MVC'sDispatcherServletlooks for these to dispatch HTTP requests.@RestController=@Controller+@ResponseBodyโ every method's return value is serialized to JSON, no view resolution.
@Component everywhere?" Technically yes โ but you lose the exception translation on repositories, and grep'ing your codebase for "where's the business logic?" becomes harder. Use the right stereotype.@Repository enables persistence-exception translation; @Controller hooks into MVC routing. The others are equivalent to @Component functionally, but communicate intent." That answer beats both extremes.Constructor vs Setter vs Field Injection โ Pick One and Don't Look Back
@Autowired. He has no idea which dependencies are required, which are optional, which are circular. He can't write a unit test without firing up Spring. This is the cost of field injection.The three styles
// 1. Constructor injection โ the recommended way @Service class CheckoutService { private final PaymentGateway gw; private final OrderRepository orders; public CheckoutService(PaymentGateway gw, OrderRepository orders) { this.gw = gw; this.orders = orders; } } // 2. Setter injection โ for optional dependencies @Service class EmailService { private MetricsClient metrics; @Autowired(required = false) public void setMetrics(MetricsClient m) { this.metrics = m; } } // 3. Field injection โ convenient, but considered an anti-pattern @Service class UserService { @Autowired private UserRepository repo; }
Why constructor injection wins
- Final fields = immutability. Compiler enforces that dependencies can't change after construction.
- No partially constructed objects. Object exists only when fully wired.
- Mandatory dependencies are obvious. Anything in the constructor is required; setters are clearly optional.
- Trivial to unit test.
new CheckoutService(fakeGw, fakeRepo). No Spring, no reflection. - Circular dependencies fail loudly at startup, not silently at runtime.
- Since Spring 4.3, the
@Autowiredannotation is optional if the class has only one constructor.
final), hides dependencies (caller can't tell what the bean needs), couples you to Spring for testing (no reflection magic, no test), and lets cycles slip in unnoticed.When Spring Has Two Beans of the Same Type โ @Qualifier, @Primary, @Profile
RazorpayGateway and a StripeGateway โ both implement PaymentGateway. She injects PaymentGateway into a service. Spring throws NoUniqueBeanDefinitionException: "two beans, one slot, you pick."Three ways to disambiguate
@Service class CheckoutService { public CheckoutService(@Qualifier("stripeGateway") PaymentGateway gw) { // Spring picks the bean named "stripeGateway" } }
@Component @Primary class RazorpayGateway implements PaymentGateway { } @Component class StripeGateway implements PaymentGateway { } // Anywhere PaymentGateway is injected without @Qualifier โ RazorpayGateway wins
@Component @Profile("prod") class RazorpayGateway implements PaymentGateway { } @Component @Profile({"dev", "test"}) class FakeGateway implements PaymentGateway { } // Switched via spring.profiles.active=dev or =prod
Combining them โ the resolution order
- Profile filtering happens first โ beans for inactive profiles aren't even registered.
- If exactly one bean of the type is registered โ injected.
- Multiple โ
@Qualifierif present picks one; else@Primarypicks one; else error.
@Profile over runtime if/else โ it keeps the wrong bean out of the context entirely, which is faster and prevents accidental usage.Auto-configuration โ How Spring Boot Reads Your Mind
spring-boot-starter-data-jpa and com.h2database:h2 to a fresh project. He writes one @Entity and one JpaRepository โ and just like that, his app starts up with an in-memory database, schema, and a fully-wired DataSource. He never wrote a @Bean. How?The mechanism, in three layers
@SpringBootApplicationis a meta-annotation =@Configuration+@ComponentScan+@EnableAutoConfiguration.@EnableAutoConfigurationimportsAutoConfigurationImportSelector, which scans every JAR forMETA-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(Spring Boot 3+) โ a plain text file listing config classes to import.- Each listed config class is gated with conditions:
@ConditionalOnClassโ only apply if a class is on the classpath.@ConditionalOnMissingBeanโ only register if the user hasn't already defined one.@ConditionalOnPropertyโ only if a property is set.@ConditionalOnWebApplicationโ only in web apps.
@AutoConfiguration @ConditionalOnClass(DataSource.class) @ConditionalOnMissingBean(DataSource.class) class DataSourceAutoConfiguration { @Bean @ConfigurationProperties("spring.datasource") public DataSource dataSource() { /* HikariCP wired with props */ } }
DataSource bean โ Spring's stays out of the way).Three flags every interviewer will probe
spring.autoconfigure.exclude=...in properties โ disable specific auto-configs.@EnableAutoConfiguration(exclude = ...)โ same thing, in code.--debugon startup โ prints the "auto-configuration report": what was matched, what was rejected and why.
@Configuration classes that only fire when the situation matches and the user hasn't already taken over.Starters & The POM โ Why You Don't Manage Versions Anymore
Starters in one sentence
A starter is a curated dependency descriptor (a JAR with no code, only a POM) that pulls in everything you need for a feature. spring-boot-starter-web brings in Spring MVC + Tomcat + Jackson + Logback โ one dependency, six bundled.
The version magic โ spring-boot-starter-parent
Your project inherits from spring-boot-starter-parent, which inherits from spring-boot-dependencies โ a giant BOM (Bill of Materials) that specifies the exact compatible version of every Spring-and-related library. So when you write:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> <!-- <version> intentionally omitted --> </dependency>
Maven looks up the version from the BOM. The whole point: stop the "library X 4.2 needs library Y 1.6 but Spring needs Y 1.5" hell that Java was famous for.
Common starters worth memorizing
| Starter | Brings in |
|---|---|
spring-boot-starter-web | MVC + embedded Tomcat + Jackson |
spring-boot-starter-webflux | Reactive web (Netty by default) |
spring-boot-starter-data-jpa | Hibernate + Spring Data JPA + HikariCP |
spring-boot-starter-data-redis | Lettuce + Spring Data Redis |
spring-boot-starter-security | Spring Security core |
spring-boot-starter-validation | Hibernate Validator (JSR-380) |
spring-boot-starter-actuator | Health, metrics, info endpoints |
spring-boot-starter-test | JUnit 5, Mockito, AssertJ, Spring Test |
spring-boot-starter-parent (e.g., your company has its own corporate parent POM), import spring-boot-dependencies as a BOM in <dependencyManagement>. You get the same version curation without the parent.application.yml, @Value, and @ConfigurationProperties
The two ways to read config
@Component class RetryService { @Value("${retry.max-attempts:3}") private int maxAttempts; // 3 is the default if the property is missing }
@Component @ConfigurationProperties(prefix = "retry") class RetryConfig { private int maxAttempts = 3; private Duration backoff = Duration.ofMillis(200); private List<String> retryableErrors; // getters and setters omitted }
Now in application.yml:
retry: max-attempts: 5 backoff: 500ms retryable-errors: - TIMEOUT - CONNECTION_RESET
When to use which
| Use | When |
|---|---|
@Value | One or two scalar properties, simple injection |
@ConfigurationProperties | A group of related properties, nested objects, lists, validation, refresh support |
Property source precedence (high to low)
- Command-line args (
--server.port=9000) SPRING_APPLICATION_JSONenvironment variable- OS environment variables
- External
application-<profile>.yml - Classpath
application-<profile>.yml - External
application.yml - Classpath
application.yml @PropertySourceannotations- Defaults (
SpringApplication.setDefaultProperties)
@Validated to your @ConfigurationProperties class and JSR-380 annotations on fields (@NotNull, @Min) to fail-fast at startup if config is wrong. A typo in production should never reach a request.Profiles โ One Codebase, Many Environments
The basics
A profile is a named slice of configuration. You write per-profile YAMLs (application-dev.yml, application-prod.yml) and per-profile beans (@Profile("dev")). Spring activates a profile via spring.profiles.active.
// 1. application.yml spring: profiles: active: dev // 2. JVM arg -Dspring.profiles.active=prod // 3. Environment variable SPRING_PROFILES_ACTIVE=prod
Multi-document YAML (Spring Boot 2.4+)
spring:
application:
name: checkout
---
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:h2:mem:test
---
spring:
config:
activate:
on-profile: prod
datasource:
url: ${PROD_DB_URL}
username: ${PROD_DB_USER}
spring.profiles.active=dev,monitoring activates both. @Profile("!prod") means "register when prod is NOT active" โ useful for dev-only beans.The Embedded Server & main() โ Why Spring Boot Apps Are Just JARs
The shift from WAR to executable JAR
Pre-Boot, you wrote a WAR and deployed it to an external Tomcat / WebSphere / WildFly. Boot inverted this: the server lives inside your app. spring-boot-starter-web bundles Tomcat as a library; the main() method bootstraps it.
@SpringBootApplication public class CheckoutApp { public static void main(String[] args) { SpringApplication.run(CheckoutApp.class, args); } }
What SpringApplication.run() does
- Creates an
ApplicationContext(which one depends on classpath: web vs reactive vs plain). - Loads property sources, activates profiles.
- Runs auto-configuration โ registers beans.
- Component scans starting from the package of the class with
@SpringBootApplication. - Calls
refresh()on the context โ instantiates singletons, runs@PostConstruct, starts embedded server. - Calls any
CommandLineRunner/ApplicationRunnerbeans.
Switching the server
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <dependency> <artifactId>spring-boot-starter-jetty</artifactId> </dependency>
DispatcherServlet โ The Front Door for Every HTTP Request
The flow, step by step
- Tomcat (or your server) accepts the TCP connection, parses HTTP, hands a
HttpServletRequestto the registered servlet. - Spring Boot registered
DispatcherServlet(mapped to/) โ it gets the request. - HandlerMapping (typically
RequestMappingHandlerMapping) looks up the matching@RequestMappingmethod by URL + method + headers + content type. - HandlerAdapter (
RequestMappingHandlerAdapter) invokes that method:- Resolves arguments via
HandlerMethodArgumentResolvers (@PathVariable,@RequestBody,@RequestParam, etc.). - Calls your method.
- Handles the return value via
HandlerMethodReturnValueHandlers.
- Resolves arguments via
- If
@ResponseBody(or@RestController) โ the return value is serialized via anHttpMessageConverter(Jackson for JSON, etc.). - If a view name is returned โ the ViewResolver resolves it to a view (Thymeleaf, JSP).
- Any exception โ caught by
HandlerExceptionResolverchain, which finds the matching@ExceptionHandleror@ControllerAdvice. - Response written back through Tomcat to the client.
REST Controller Anatomy โ All the Annotations You Actually Need
@RestController @RequestMapping("/api/v1/orders") class OrderController { private final OrderService svc; public OrderController(OrderService svc) { this.svc = svc; } @GetMapping("/{id}") public OrderDto getOne(@PathVariable String id) { return svc.findById(id); } @GetMapping public Page<OrderDto> list( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestHeader("X-Tenant-Id") String tenant) { return svc.list(tenant, page, size); } @PostMapping @ResponseStatus(HttpStatus.CREATED) public OrderDto create(@Valid @RequestBody CreateOrderRequest req) { return svc.create(req); } @PutMapping("/{id}") public ResponseEntity<OrderDto> update( @PathVariable String id, @RequestBody UpdateOrderRequest req) { OrderDto updated = svc.update(id, req); return ResponseEntity.ok().eTag(updated.version()).body(updated); } @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable String id) { svc.delete(id); } }
Annotations cheatsheet
| Annotation | Purpose |
|---|---|
@RestController | @Controller + @ResponseBody on every method |
@RequestMapping | Class-level URL prefix (or method-level catch-all) |
@GetMapping / @PostMapping / @PutMapping / @PatchMapping / @DeleteMapping | Method-level shortcuts |
@PathVariable | Extracts URL segments (/orders/{id}) |
@RequestParam | Query string params (?page=0) |
@RequestBody | Deserialize HTTP body into a Java object (Jackson) |
@RequestHeader | Read a header value |
@ResponseBody | Serialize return value (already implied by @RestController) |
@ResponseStatus | Override default 200 with a status (201, 204, etc.) |
ResponseEntity<T> | Programmatic control: status + headers + body |
When to return ResponseEntity vs a plain DTO
- Plain DTO โ simplest, fine for 99% of GETs.
ResponseEntityโ when you need custom headers (ETag, Location), conditional status, or a body that depends on logic (200 vs 304).
@RequestBody on the parameter, or content-type isn't application/json. Use Java records (Spring Boot 2.6+) โ they work natively with Jackson.Exception Handling โ @ControllerAdvice, @ExceptionHandler, ProblemDetail
The pattern โ global advice
@RestControllerAdvice class GlobalExceptionHandler { @ExceptionHandler(EntityNotFoundException.class) public ResponseEntity<ProblemDetail> notFound(EntityNotFoundException ex) { ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(p); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String, String>> validation(MethodArgumentNotValidException ex) { Map<String, String> errors = new HashMap<>(); ex.getBindingResult().getFieldErrors() .forEach(e -> errors.put(e.getField(), e.getDefaultMessage())); return ResponseEntity.badRequest().body(errors); } @ExceptionHandler(Exception.class) public ResponseEntity<ProblemDetail> fallback(Exception ex) { log.error("Unhandled", ex); return ResponseEntity.status(500) .body(ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong")); } }
ProblemDetail โ RFC 7807 (Spring 6+)
Spring 6 / Boot 3 ship ProblemDetail, a built-in type that follows RFC 7807 (Problem Details for HTTP APIs). One standard error shape across services beats every team inventing their own.
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Order 12345 not found",
"instance": "/api/v1/orders/12345"
}
@ControllerAdvice applies to ALL controllers by default. To scope it: @ControllerAdvice(basePackages = "com.example.api.v2") or @ControllerAdvice(annotations = AdminApi.class).EntityNotFoundException takes precedence over one for RuntimeException. Spring resolves this automatically by class hierarchy.Bean Validation โ @Valid, @Validated, JSR-380
The annotations you'll use 95% of the time
| Annotation | Validates |
|---|---|
@NotNull | Not null |
@NotBlank | String not null and not whitespace-only |
@NotEmpty | Collection / array / string not empty |
@Size(min, max) | String / collection length |
@Min / @Max | Numeric bounds |
@Email | RFC-style email format |
@Pattern(regexp) | Regex match |
@Past / @Future | Date/time bounds |
record CreateOrderRequest( @NotBlank String userId, @NotEmpty List<@Valid LineItem> items, @Min(1) int quantity ) {} @PostMapping public OrderDto create(@Valid @RequestBody CreateOrderRequest req) { // if validation fails, MethodArgumentNotValidException is thrown // โ 400 Bad Request, handled by @ControllerAdvice }
@Valid vs @Validated
@Validโ JSR-380 standard. Triggers nested validation.@Validatedโ Spring's variant. Adds group support (validate different rules at different times) and works on method parameters in any Spring bean (not just controllers).
Custom constraint
@Constraint(validatedBy = SkuValidator.class) @Target({ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) public @interface ValidSku { String message() default "invalid SKU"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; } class SkuValidator implements ConstraintValidator<ValidSku, String> { public boolean isValid(String sku, ConstraintValidatorContext ctx) { return sku != null && sku.matches("[A-Z]{3}-\\d{4}"); } }
spring-boot-starter-validation on the classpath, @Valid on a controller parameter silently does nothing. Spring Boot 2.3+ removed it from spring-boot-starter-web โ you must add it explicitly.Filters, Interceptors, and AOP โ Three Layers of Cross-Cutting Concerns
The three layers, outside-in
| Layer | Where it sits | Sees | Use for |
|---|---|---|---|
| Servlet Filter | Servlet container, before DispatcherServlet | Raw request/response, no controller info | Logging, GZIP, CORS, basic auth |
| HandlerInterceptor | Inside DispatcherServlet, after handler resolution | The matched handler method | MDC setup, auth checks per route, timing |
| AOP Aspect | Around any Spring bean method | Method args, return value, exceptions | Transactions, caching, retries, custom logging |
@Aspect @Component class TimingAspect { @Around("@annotation(Timed)") public Object time(ProceedingJoinPoint pjp) throws Throwable { long start = System.nanoTime(); try { return pjp.proceed(); } finally { log.info("{} took {}ms", pjp.getSignature(), (System.nanoTime() - start) / 1_000_000); } } }
How Spring AOP actually works
Spring AOP is proxy-based. When the container creates a bean that has methods to advise, it wraps the bean in a proxy object. Callers get the proxy. The proxy intercepts every method call, runs the advice, and delegates to the real method.
- If the bean implements an interface โ Spring uses a JDK dynamic proxy.
- If not โ Spring uses CGLIB (subclass proxy).
this.foo() from another method in the same bean bypasses the proxy. So @Transactional, @Cacheable, and your custom advice all silently no-op when called internally. Fix: call through an injected reference, or use AspectJ load-time weaving for cross-cutting at the bytecode level.Spring Data JPA โ The Repository Pattern, Crystallized
User by email. The next day, Karthik finishes the same task in 4 lines: declare an interface, write one method signature, done. Raj wonders if Karthik is using witchcraft. He's using Spring Data JPA.The repository hierarchy
Repository<T, ID>โ marker, no methods.CrudRepository<T, ID>โ addssave,findById,delete,count.PagingAndSortingRepository<T, ID>โ addsPage<T> findAll(Pageable).JpaRepository<T, ID>โ adds JPA-specific bits likeflush(),saveAndFlush(), batch ops.
interface UserRepository extends JpaRepository<User, Long> { // SELECT * FROM users WHERE email = ? Optional<User> findByEmail(String email); // SELECT * FROM users WHERE active = true ORDER BY created_at DESC List<User> findByActiveTrueOrderByCreatedAtDesc(); // SELECT COUNT(*) FROM users WHERE org_id = ? long countByOrgId(String orgId); // Custom JPQL when name parsing isn't enough @Query("select u from User u where u.email like :pattern") List<User> searchByEmail(@Param("pattern") String pattern); // Native SQL when JPQL isn't enough @Query(value = "SELECT * FROM users WHERE created_at > NOW() - INTERVAL '7 days'", nativeQuery = true) List<User> recentlyCreated(); }
How method names become queries
Spring Data parses your method name at startup using a state machine: findBy + property + (operator) + connector + property. Operators include And, Or, Like, StartingWith, GreaterThan, Between, In, IsNull. If the parser can't resolve a property, the app fails fast at startup โ not at runtime.
Pagination & sorting
Pageable page = PageRequest.of(0, 20, Sort.by("createdAt").descending()); Page<User> result = repo.findAll(page); result.getContent(); // the items result.getTotalElements(); // total count (extra COUNT query) result.getTotalPages(); // total pages result.hasNext(); // more to come?
Page issues an extra COUNT(*) query on every call โ fine for small tables, expensive for huge ones. For "infinite scroll" UIs, return Slice<T> instead โ same data, no count query.@Transactional โ The Annotation Most Devs Get Wrong
The mechanics
Spring wraps your bean in an AOP proxy. When you call an @Transactional method, the proxy:
- Asks the
PlatformTransactionManagerfor a transaction (start a new one or join an existing one based on propagation). - Invokes your method.
- If your method completes normally โ commit.
- If your method throws an unchecked exception (RuntimeException) or Error โ rollback.
- Checked exceptions โ commit (unless you set
rollbackFor).
Propagation โ what to do when a transaction already exists
| Value | Behavior |
|---|---|
REQUIRED (default) | Join existing or start new |
REQUIRES_NEW | Always suspend existing, start new (commits independently) |
SUPPORTS | Run inside if exists, otherwise non-transactional |
MANDATORY | Must be inside an existing transaction (else error) |
NEVER | Must NOT be inside a transaction |
NESTED | Savepoint-based nested transaction (DB support required) |
Isolation โ what dirty reads / phantoms / etc. you allow
| Level | Prevents |
|---|---|
READ_UNCOMMITTED | Nothing โ sees uncommitted writes |
READ_COMMITTED | Dirty reads (default in PostgreSQL, Oracle) |
REPEATABLE_READ | Dirty + non-repeatable reads (default in MySQL InnoDB) |
SERIALIZABLE | Everything โ including phantom reads (slowest) |
Common bugs
this โ AOP proxy bypassed โ no transaction. Fix: call via an injected self-reference or split into two beans.throw new IOException("...") commits the transaction. Add @Transactional(rollbackFor = Exception.class) if that's what you want.@Transactional method that does I/O (HTTP calls, sleeps) holds a DB connection the whole time. Pool exhausts under load. Keep transactions short; do non-DB work outside them.@Transactional(readOnly = true) on read-only services is a free win โ Hibernate skips dirty checking, and some pools route to read replicas.N+1, Lazy Loading, and the FetchType Trap
The setup
@Entity class Order { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer; // ... } // Then: List<Order> orders = repo.findAll(); // 1 query for (Order o : orders) { String name = o.getCustomer().getName(); // triggers 1 query per order! }
Result: 1 + N queries. With 1000 orders, 1001 round-trips to the DB.
Three fixes
@Query("select o from Order o join fetch o.customer") List<Order> findAllWithCustomer();
@EntityGraph(attributePaths = {"customer", "items"}) List<Order> findAll();
# in application.yml spring.jpa.properties.hibernate.default_batch_fetch_size: 50 # Now Hibernate loads up to 50 customers in ONE query: WHERE customer_id IN (...)
FetchType โ which default to pick
FetchType.LAZYโ load when accessed. Default for@OneToMany/@ManyToMany.FetchType.EAGERโ load with the parent. Default for@ManyToOne/@OneToOne.
findById. Make everything LAZY by default; opt into eager loading per-query with JOIN FETCH or EntityGraph.LazyInitializationException happens when you access a lazy field outside a transaction or session โ the entity is "detached" and can't reach back to the DB. Common with controllers returning entities directly. Fix: use DTOs, fetch what you need inside the transaction, or use Open-Session-In-View (controversial โ better to fix the root cause).Caching โ @Cacheable, @CacheEvict, and Why Your Cache Lies
The annotations
@Service class UserService { @Cacheable(value = "users", key = "#id") public User findById(String id) { return repo.findById(id).orElseThrow(); } @CachePut(value = "users", key = "#user.id") public User update(User user) { return repo.save(user); // run AND update cache } @CacheEvict(value = "users", key = "#id") public void delete(String id) { repo.deleteById(id); } @CacheEvict(value = "users", allEntries = true) public void clearAll() { } }
And on the application class:
@SpringBootApplication @EnableCaching class App { } # Add to pom.xml for Caffeine (in-memory) or Redis (distributed): # spring-boot-starter-cache + caffeine, or + spring-boot-starter-data-redis
Cache pitfalls
@Transactional โ calling a cached method from another method in the same bean bypasses the proxy and skips the cache.findById on a non-existent key gets cached as null forever (or until eviction). Use unless = "#result == null" to skip caching nulls.@Cacheable auto-serializes the return value. Make sure your DTOs are Serializable or use Jackson serializers โ and never cache mutable entity proxies (Hibernate HibernateProxy mid-session โ boom).@Async and @Scheduled โ Background Work, Made Easy
@SpringBootApplication @EnableAsync @EnableScheduling class App { } @Service class EmailService { @Async public CompletableFuture<Void> sendWelcome(User u) { // runs on a different thread; returns immediately to caller smtp.send(u.email, "Welcome!"); return CompletableFuture.completedFuture(null); } } @Component class CleanupJob { @Scheduled(cron = "0 0 3 * * *") // every day at 3 AM public void deleteOldOrders() { ... } @Scheduled(fixedDelay = 10_000) // 10s after previous run finishes public void heartbeat() { ... } @Scheduled(fixedRate = 60_000) // every 60s, regardless of duration public void poll() { ... } }
@Async โ what to know
- Methods can return
void,CompletableFuture<T>, orListenableFuture<T>. - Same self-invocation rule โ calling
this.someAsyncMethod()from within the same bean runs synchronously. - By default, uses
SimpleAsyncTaskExecutorโ creates a new thread per call. Replace it: define aTaskExecutorbean namedtaskExecutoror aThreadPoolTaskExecutorbean.
@Scheduled โ what to know
- Default thread pool is single-threaded โ long-running jobs delay other jobs. Add a
ThreadPoolTaskSchedulerbean for parallelism. - Cron uses Spring's 6-field syntax (
second minute hour day-of-month month day-of-week) โ different from Linux cron's 5 fields. - In a multi-instance deployment, every instance fires the schedule. For "run only once across the cluster," use ShedLock or quartz.
@EnableAsync, or the method is private/final.Spring Security โ Filter Chain, Authentication, Authorization
The big picture
Spring Security is a chain of servlet filters that intercepts every request before it reaches your controller. Each filter has one job: parse a JWT, check a session, authorize a route, etc. The chain ends at FilterSecurityInterceptor, which decides authorize-or-deny.
- SecurityContextPersistenceFilter โ load existing auth from session, store at end.
- UsernamePasswordAuthenticationFilter โ for form login posts.
- BasicAuthenticationFilter โ for HTTP Basic.
- BearerTokenAuthenticationFilter โ for JWTs (OAuth2 resource server).
- ExceptionTranslationFilter โ converts security exceptions into HTTP responses.
- FilterSecurityInterceptor โ the final authorization gate.
Modern config (Spring Security 6+)
@Configuration @EnableWebSecurity class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(c -> c.disable()) .authorizeHttpRequests(a -> a .requestMatchers("/api/public/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .build(); } }
Authentication vs Authorization
- Authentication โ "who are you?" Verifies credentials, produces an
Authenticationobject. - Authorization โ "what are you allowed to do?" Checks the principal's authorities/roles against the resource's requirements.
Method-level security
@EnableMethodSecurity class SecurityConfig { } @Service class OrderService { @PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id") public List<Order> forUser(String userId) { ... } }
JWT & OAuth2 โ Stateless Auth Without the Pain
JWT in 60 seconds
A JWT is three Base64-encoded parts joined by dots: header.payload.signature. The header says the algorithm; the payload holds claims (subject, expiry, custom data); the signature proves the token wasn't tampered with.
- Stateless โ server doesn't store sessions; the token IS the session.
- Self-contained โ every claim the API needs is in the token (user id, roles, expiry).
- Verifiable โ server validates signature with a known key (HMAC) or public key (RSA/EC).
The Spring Security 6 setup
# pom.xml: spring-boot-starter-oauth2-resource-server # application.yml spring: security: oauth2: resourceserver: jwt: issuer-uri: https://auth.example.com # Spring auto-discovers /.well-known/openid-configuration # and pulls the JWKS endpoint to verify signatures
That's it. Spring registers the right filter, validates each incoming JWT, and exposes the principal as Jwt or your custom converter's output.
OAuth2 โ the four roles
| Role | Who |
|---|---|
| Resource Owner | The user (you) |
| Client | The app asking for access |
| Authorization Server | Issues tokens (Auth0, Okta, Cognito) |
| Resource Server | Your API โ validates tokens, serves data |
The grant flows
- Authorization Code (with PKCE) โ for SPAs and mobile apps. The modern default.
- Client Credentials โ for service-to-service (no user involved).
- Resource Owner Password โ deprecated; never use for new apps.
- Implicit โ deprecated; use Auth Code + PKCE instead.
exp claim. For "log out everywhere," either keep tokens short-lived (5โ15 min) and use a refresh token, or maintain a token-blacklist (giving up half the statelessness).Actuator & Observability โ Production-Ready Endpoints, Free
Add spring-boot-starter-actuator and you instantly get:
| Endpoint | Returns |
|---|---|
/actuator/health | UP/DOWN, with sub-checks (DB, Redis, disk) |
/actuator/info | App version, git commit, build time |
/actuator/metrics | JVM, HTTP, DB pool, GC counters |
/actuator/prometheus | Prometheus-formatted metrics (with Micrometer) |
/actuator/loggers | Read/change log levels at runtime |
/actuator/env | All properties + their source |
/actuator/threaddump | JVM thread dump |
/actuator/heapdump | Heap dump (for memory leaks) |
/actuator/mappings | All registered URL mappings |
management:
endpoints:
web:
exposure:
include: "health,info,metrics,prometheus"
endpoint:
health:
show-details: when_authorized
metrics:
tags:
application: ${spring.application.name}
environment: ${spring.profiles.active}
Custom HealthIndicator
@Component class PaymentGatewayHealth implements HealthIndicator { @Override public Health health() { if (gateway.ping()) return Health.up().build(); return Health.down().withDetail("reason", "timeout").build(); } }
Liveness vs Readiness (Kubernetes)
/actuator/health/livenessโ "is the JVM alive?" K8s restarts the pod if this fails./actuator/health/readinessโ "ready to serve traffic?" K8s removes the pod from load-balancer rotation if this fails.
/actuator/env publicly โ it leaks your DB URL, secrets in property values, and feature flags. Default to health, info, metrics, prometheus only; lock the rest behind a private network or auth.Testing Strategy โ Unit, Slice, Integration
The pyramid
| Type | Annotation | What loads | Speed |
|---|---|---|---|
| Plain unit | None / @ExtendWith(MockitoExtension.class) | Nothing โ just your class + mocks | Fast (ms) |
| Web slice | @WebMvcTest(OrderController.class) | Controllers + filters, no service / DB | ~1s |
| Data slice | @DataJpaTest | JPA + in-memory DB, no controllers | ~1s |
| Full integration | @SpringBootTest | Full app context | 5โ20s |
@WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired MockMvc mvc; @MockBean OrderService svc; @Test void getOrderReturnsJson() throws Exception { when(svc.findById("o-1")).thenReturn(new OrderDto("o-1", 499.0)); mvc.perform(get("/api/v1/orders/o-1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value("o-1")); } }
Testcontainers โ the "real" integration test
Don't test against H2 if production runs Postgres โ Hibernate's SQL diverges between dialects. Spin up a real Postgres in a container:
@SpringBootTest @Testcontainers class OrderIntegrationTest { @Container static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16"); @DynamicPropertySource static void props(DynamicPropertyRegistry r) { r.add("spring.datasource.url", pg::getJdbcUrl); r.add("spring.datasource.username", pg::getUsername); r.add("spring.datasource.password", pg::getPassword); } // ... tests }
@SpringBootTest's context is cached across tests with the same config โ fast on the second run. Mixing too many configurations forces re-creation and slows the suite massively.Microservices & Spring Cloud โ Service Discovery, Config, Gateway
The building blocks
| Concern | Spring Cloud answer |
|---|---|
| Service discovery | Eureka (Netflix), Consul, or K8s-native DNS |
| Config management | Spring Cloud Config Server |
| Client-side load balancing | Spring Cloud LoadBalancer (replaces Ribbon) |
| Synchronous comms | OpenFeign declarative HTTP client |
| API Gateway | Spring Cloud Gateway (reactive) |
| Distributed tracing | Micrometer Tracing + OpenTelemetry / Zipkin |
| Resilience | Resilience4j (retry, circuit breaker, bulkhead) |
| Async messaging | Spring Cloud Stream over Kafka / RabbitMQ |
OpenFeign โ calling services without writing HTTP plumbing
@FeignClient(name = "inventory-service") interface InventoryClient { @GetMapping("/items/{sku}") Item getItem(@PathVariable String sku); } // Then just: @Service class CheckoutService { private final InventoryClient inventory; // ... inventory.getItem("SKU-42"); // Spring generates the HTTP call }
Spring Cloud Gateway routes
spring:
cloud:
gateway:
routes:
- id: orders
uri: lb://orders-service
predicates:
- Path=/api/v1/orders/**
filters:
- StripPrefix=2
- AddRequestHeader=X-Origin, gateway
Resilience4j โ Retry, Circuit Breaker, Bulkhead, Rate Limiter
The four patterns
- Retry โ try again on transient failure (with backoff).
- Circuit Breaker โ stop calling a flaky dependency; fail fast for a cooldown window.
- Bulkhead โ limit concurrent calls so one slow dependency can't exhaust the whole thread pool.
- Rate Limiter โ cap calls per time window.
@Service class PaymentService { @CircuitBreaker(name = "razorpay", fallbackMethod = "fallback") @Retry(name = "razorpay") public PaymentResult charge(Order o) { return client.post("/charge", o); } public PaymentResult fallback(Order o, Throwable t) { return PaymentResult.queued(o.id); // degrade gracefully } }
resilience4j:
circuitbreaker:
instances:
razorpay:
failure-rate-threshold: 50 # open if >50% of last N calls fail
sliding-window-size: 10
wait-duration-in-open-state: 30s # cooldown before half-open
retry:
instances:
razorpay:
max-attempts: 3
wait-duration: 200ms
exponential-backoff-multiplier: 2
Circuit breaker states
- CLOSED โ normal; calls go through.
- OPEN โ failure threshold breached; calls fail-fast without hitting the dependency.
- HALF_OPEN โ after wait duration, allow a few probe calls. If they succeed โ CLOSED. If they fail โ back to OPEN.
Spring Boot 3 & Native Image โ Why You'll Care About AOT
The big shifts in Spring Boot 3
- Java 17 minimum โ finally drops Java 8.
jakarta.*instead ofjavax.*โ the namespace move that broke half of the Java ecosystem in 2022.- Spring Framework 6 as the base.
- First-class GraalVM native image support via Spring AOT.
- Observability via Micrometer (replaces Sleuth) โ tracing + metrics in one library.
- HTTP interface clients โ declarative HTTP without OpenFeign.
Why native images matter
GraalVM ahead-of-time compiles your Spring Boot app to a native executable. Trade-offs:
| Metric | JVM | Native |
|---|---|---|
| Startup time | 2โ10 seconds | ~50ms |
| Peak memory | 200โ500 MB | 50โ100 MB |
| Peak throughput | Higher (after JIT warmup) | Lower (no JIT) |
| Build time | Seconds | Minutes |
| Reflection / dynamic proxies | Free | Must be declared at build time |
Use native for serverless (cold start matters), CLI tools, low-memory containers. Stay on JVM for long-running, high-throughput services.
@RegisterReflectionForBinding or hint files.Tricky Gotchas & Curveballs Interviewers Love
These appear when interviewers want to separate "I followed a tutorial" from "I shipped Spring." Memorize a few; they pay back massively.
1. Self-invocation kills annotations
Calling this.foo() from another method in the same bean bypasses the AOP proxy. @Transactional, @Cacheable, @Async, custom aspects โ all silently no-op. Fix: inject self-reference, split into two beans, or use AspectJ load-time weaving.
2. @Value on a static field doesn't work
Spring injects values via setters/fields on instances. Statics are class-level, set before any instance exists. Use a non-static field, or @PostConstruct to copy a non-static into a static.
3. Bean creation order is not source order
Spring resolves the dependency graph and instantiates in topological order. Don't assume "A is declared above B โ A is created first." If you need explicit ordering, use @DependsOn.
4. Circular dependencies
A โ needs B โ needs A. Spring used to allow this with field/setter injection by partial wiring. Spring Boot 2.6+ fails on startup by default. Fix: refactor (the cycle is a design smell), use @Lazy on one side, or set spring.main.allow-circular-references=true (don't).
5. List<MyBean> auto-injection
Spring will inject all beans of type MyBean as a list. Useful for plugin patterns ("apply every OrderValidator"). Combine with @Order for deterministic ordering.
@Service class OrderProcessor { private final List<OrderValidator> validators; // all impls injected public void validate(Order o) { validators.forEach(v -> v.validate(o)); } }
6. @RequestParam required-true is the default
Forgot to mark it optional? Missing param = 400 Bad Request. Use @RequestParam(required=false) or a default: @RequestParam(defaultValue="0").
7. @Transactional on JPA: changes outside the transaction are NOT flushed
Setting an entity field outside a @Transactional method (or after commit) doesn't persist. The entity becomes "detached." A common WTF moment.
8. save() doesn't always trigger an INSERT
Spring Data's save() returns the merged entity. For a NEW entity (id=null), Hibernate INSERTs. For an EXISTING one, it's a SELECT-then-UPDATE โ and if no fields changed, no UPDATE. Don't assume one DB hit per save().
9. @MockBean changes the context
Each unique mock combination = a different ApplicationContext = re-bootstrap on every test class. Slow test suites often trace to this. Standardize @MockBean usage; consider @SpyBean sparingly.
10. @SpringBootApplication position matters
Component scan starts from the package of the @SpringBootApplication class. Put it in com.example.app and it scans com.example.app and below. A bean in com.example.other won't be picked up. Always place the main class at the root package.
11. application.properties and application.yml together
You can have both. Spring loads both, but application.properties takes precedence over application.yml. If a property exists in both, properties wins. Pick one and stick with it.
12. @Bean vs @Component
@Component is on the class โ Spring instantiates it via constructor. @Bean is on a method inside @Configuration โ you control how the bean is built. Use @Bean when you need to wire third-party classes (you can't put @Component on someone else's library class) or when construction has logic.
13. @RestController + entity return = potential problem
Returning a JPA entity directly from a controller serializes lazy associations (LazyInitializationException) or pulls the world (eager). Return DTOs, not entities. The DTO mapping is one boilerplate step that pays back tenfold.
14. JSON deserialization without default constructor
Jackson needs either a no-arg constructor + setters, or constructor with @JsonCreator. Java records work out of the box (Jackson 2.12+).
15. @RequestBody with a String parameter
Trips up some devs: with content-type application/json, Spring tries to parse the body as a JSON-encoded String. Use consumes = "text/plain" or accept a byte[]/InputStream for raw bodies.
16. Logging defaults โ Logback, not Log4j
Spring Boot ships Logback as the default. Bringing in Log4j 2 means excluding spring-boot-starter-logging first, else both load and log lines double.
17. WebClient vs RestTemplate
RestTemplate is in maintenance mode (no new features) but not deprecated. WebClient (from WebFlux) is recommended for new code โ it's reactive but works synchronously too. In Spring 6.1+, RestClient is the new sync-friendly alternative โ WebClient-style fluent API without the reactive types.
18. Property placeholder fallbacks
${db.url:jdbc:h2:mem:default} โ colon = default if missing. Saves countless "missing property" startup failures in dev.
19. spring.jpa.open-in-view defaults to TRUE
This is the controversial one. It keeps the Hibernate session open through the entire request โ so lazy associations work in views/controllers. But it also: hides N+1 (now they happen during rendering), holds DB connections longer, and masks design issues. Most teams turn it off (false) and load eagerly via DTOs / EntityGraphs.
20. @Configuration vs @Configuration(proxyBeanMethods = false)
By default, @Configuration classes are CGLIB-proxied so calls between @Bean methods return the singleton, not a new instance. Setting proxyBeanMethods = false skips the proxy โ faster startup, smaller native images, but you lose that singleton-via-method-call magic. Spring Boot 2.2+ uses this in its own auto-configurations.