Table of Contents
1. Overview & Architecture
Spring Framework is a comprehensive Java application framework built around dependency injection and AOP. Spring Boot layers convention over configuration on top: auto-configuration, embedded servers, opinionated starters, and production-ready defaults — so you write application code, not plumbing.
| Concern |
Spring Framework |
Spring Boot |
| Server setup |
Manual WAR, external Tomcat |
Embedded Tomcat/Jetty/Undertow, runnable JAR |
| Configuration |
Extensive XML or @Bean wiring |
Auto-configuration via classpath detection |
| Dependencies |
Pick versions manually, resolve conflicts |
Starters manage curated, compatible versions |
| Metrics/health |
Manual integration |
Actuator out of the box |
| Native images |
Complex setup |
First-class GraalVM support via Buildpacks |
Auto-configuration
Spring Boot scans the classpath and registers beans automatically. If spring-boot-starter-data-jpa is on the classpath and a DataSource bean exists, a full JPA context is wired for you. Auto-configuration classes are registered in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports Boot 3 (formerly spring.factories).
How auto-configuration is ordered
Auto-config classes use
@ConditionalOnMissingBean so your own beans always win. They are a safety net, not a constraint.
Spring Boot 3.x / Spring Framework 6 — Key Changes
- Jakarta EE 9+ namespace —
javax.* → jakarta.* (imports, annotations, servlet API)
- Java 17 baseline — records, sealed classes, pattern matching, text blocks are all first-class
- GraalVM native image — ahead-of-time (AOT) compilation support built in; compile with
./mvnw native:compile -Pnative
- Observability — Micrometer Tracing replaces Spring Cloud Sleuth
- ProblemDetail (RFC 7807) as a first-class error response format
- RestClient — new synchronous HTTP client replacing
RestTemplate
- HTTP Interface — declare HTTP clients as Java interfaces (like Feign)
Starters
Starters are dependency descriptors that pull in a curated set of libraries. Common starters:
| Starter | Brings in |
spring-boot-starter-web | Tomcat, Spring MVC, Jackson |
spring-boot-starter-data-jpa | Hibernate, Spring Data JPA, HikariCP |
spring-boot-starter-security | Spring Security, crypto |
spring-boot-starter-actuator | Micrometer, health/metrics endpoints |
spring-boot-starter-test | JUnit 5, Mockito, AssertJ, MockMvc |
spring-boot-starter-webflux | Reactor, WebFlux, Netty |
spring-boot-starter-validation | Hibernate Validator (Jakarta Bean Validation) |
spring-boot-starter-cache | Spring Cache abstraction |
spring-boot-starter-amqp | Spring AMQP, RabbitMQ client |
spring-boot-starter-kafka | Spring Kafka, Kafka client |
Spring Initializr
Generate projects at
start.spring.io. Pick dependencies, language (Java/Kotlin/Groovy), and build tool (Maven/Gradle). The generated zip has a working project in seconds.
2. Project Setup
Standard Project Structure
my-app/
├── src/
│ ├── main/
│ │ ├── java/com/example/myapp/
│ │ │ ├── MyAppApplication.java # Main class
│ │ │ ├── config/ # @Configuration classes
│ │ │ ├── controller/ # @RestController classes
│ │ │ ├── service/ # @Service classes
│ │ │ ├── repository/ # @Repository interfaces
│ │ │ ├── domain/ # @Entity / model classes
│ │ │ └── dto/ # Data Transfer Objects
│ │ └── resources/
│ │ ├── application.yml # Main config
│ │ ├── application-dev.yml # Dev profile config
│ │ ├── application-prod.yml # Prod profile config
│ │ ├── db/migration/ # Flyway migrations (V1__init.sql)
│ │ └── static/ # Static web assets
│ └── test/
│ ├── java/com/example/myapp/
│ │ ├── controller/ # @WebMvcTest tests
│ │ ├── service/ # Unit tests
│ │ └── repository/ # @DataJpaTest tests
│ └── resources/
│ └── application-test.yml # Test config
├── pom.xml # or build.gradle
└── Dockerfile
Maven pom.xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.3</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Creates executable fat JAR -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
Gradle equivalent (build.gradle.kts)
plugins {
id("org.springframework.boot") version "3.2.3"
id("io.spring.dependency-management") version "1.1.4"
kotlin("jvm") version "1.9.22"
kotlin("plugin.spring") version "1.9.22"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
Main Application Class
package com.example.myapp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// @SpringBootApplication = @Configuration + @EnableAutoConfiguration + @ComponentScan
@SpringBootApplication
public class MyAppApplication {
public static void main(String[] args) {
SpringApplication.run(MyAppApplication.class, args);
}
}
Component scanning scope
By default,
@ComponentScan scans the package of the main class and all sub-packages. Place your main class at the root package (e.g.,
com.example.myapp) so all classes in
com.example.myapp.* are picked up automatically.
Running the Application
# Development (hot reload with DevTools)
./mvnw spring-boot:run
# With a specific profile
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
# Build and run fat JAR
./mvnw package -DskipTests
java -jar target/my-app-1.0.0.jar
# Override config at runtime
java -jar app.jar --server.port=9090 --spring.profiles.active=prod
# Gradle
./gradlew bootRun
./gradlew bootJar
Spring Boot DevTools
Add
spring-boot-devtools for automatic restart on classpath changes during development. It uses two classloaders — base (libraries) and restart (your code) — so restarts are fast (~1-2s vs cold start).
3. Configuration
application.yml vs application.properties
Both are equivalent. YAML is preferred for hierarchical config — it's less repetitive.
# application.yml
spring:
application:
name: my-app
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USER:myuser} # env var with default
password: ${DB_PASS} # env var, no default = required
hikari:
maximum-pool-size: 20
connection-timeout: 30000
jpa:
hibernate:
ddl-auto: validate # never auto-drop in prod
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
server:
port: 8080
servlet:
context-path: /api
shutdown: graceful # drain in-flight requests on SIGTERM
# Custom properties (bound via @ConfigurationProperties)
app:
security:
jwt-secret: ${JWT_SECRET}
jwt-expiration-ms: 86400000
features:
new-ui-enabled: true
rate-limit:
requests-per-minute: 60
@Value — Simple Injection
@Service
public class NotificationService {
// Inject a single property; "${}" syntax = property reference
@Value("${app.notifications.email-from:[email protected]}")
private String emailFrom;
// SpEL expression — inject a computed value
@Value("#{${app.rate-limit.requests-per-minute} * 60}")
private int requestsPerHour;
}
Prefer @ConfigurationProperties over @Value
@Value is fine for one-offs. For groups of related properties,
@ConfigurationProperties gives you type safety, IDE autocompletion, and validation.
@ConfigurationProperties — Type-Safe Config
// Step 1: Define the properties class (record preferred in Java 17+)
@ConfigurationProperties(prefix = "app.security")
public record SecurityProperties(
String jwtSecret,
long jwtExpirationMs
) {}
// Step 2: Enable it (in @SpringBootApplication class or any @Configuration)
@SpringBootApplication
@EnableConfigurationProperties(SecurityProperties.class) // or use @ConfigurationPropertiesScan
public class MyAppApplication { ... }
// Step 3: Inject and use
@Service
public class TokenService {
private final SecurityProperties secProps;
public TokenService(SecurityProperties secProps) {
this.secProps = secProps;
}
public String generateToken(String subject) {
return Jwts.builder()
.subject(subject)
.expiration(new Date(System.currentTimeMillis() + secProps.jwtExpirationMs()))
.signWith(Keys.hmacShaKeyFor(secProps.jwtSecret().getBytes()))
.compact();
}
}
Profiles
# application-dev.yml — only active when spring.profiles.active=dev
spring:
datasource:
url: jdbc:h2:mem:testdb
jpa:
show-sql: true
devtools:
restart:
enabled: true
logging:
level:
com.example: DEBUG
org.hibernate.SQL: DEBUG
// Conditionally register beans per profile
@Configuration
@Profile("dev")
public class DevConfig {
@Bean
public CommandLineRunner seedData(UserRepository repo) {
return args -> {
repo.save(new User("[email protected]", "Admin User"));
};
}
}
// A bean active in all profiles EXCEPT prod
@Profile("!prod")
@Service
public class MockEmailService implements EmailService { ... }
Externalized Config Precedence (highest to lowest)
- Command-line arguments (
--key=value)
SPRING_APPLICATION_JSON environment variable
- OS environment variables
application-{profile}.yml outside the JAR
application.yml outside the JAR
application-{profile}.yml inside the JAR (classpath)
application.yml inside the JAR (classpath)
@PropertySource annotated classes
- Default properties (
SpringApplication.setDefaultProperties)
12-factor config
For production, set secrets via OS environment variables (never commit them). Spring Boot maps
SERVER_PORT →
server.port automatically (uppercase, underscores replace dots).
@ConditionalOnProperty
// Register bean only when a feature flag is true
@Bean
@ConditionalOnProperty(name = "app.features.new-ui-enabled", havingValue = "true")
public NewUiController newUiController() {
return new NewUiController();
}
// Other useful conditionals
@ConditionalOnMissingBean(DataSource.class) // only if no DataSource defined
@ConditionalOnClass(name = "com.redis.RedisClient") // only if class is present
@ConditionalOnWebApplication // only in web context
4. Dependency Injection & IoC
Stereotype Annotations
| Annotation | Purpose | Special behavior |
@Component | Generic managed bean | Base of all stereotypes |
@Service | Business logic layer | None (semantic marker) |
@Repository | Data access layer | Translates DB exceptions to DataAccessException |
@Controller | Web MVC controller | Returns view names |
@RestController | REST API controller | @Controller + @ResponseBody |
@Configuration | Config class with @Bean methods | Subclassed by CGLIB proxy for singleton semantics |
Constructor Injection (Preferred)
@Service
public class OrderService {
private final OrderRepository orderRepo;
private final PaymentService paymentService;
private final EmailService emailService;
// Constructor injection: explicit dependencies, easy to test (no Spring required)
// @Autowired is optional when there is exactly one constructor (Spring 4.3+)
public OrderService(OrderRepository orderRepo,
PaymentService paymentService,
EmailService emailService) {
this.orderRepo = orderRepo;
this.paymentService = paymentService;
this.emailService = emailService;
}
}
Why constructor injection?
- Makes dependencies explicit — can't forget to inject them
- Enables
final fields (immutability)
- Works without Spring in unit tests (just
new MyService(mockDep))
- Detects circular dependencies at startup, not at runtime
Field and setter injection (avoid in production code)
// Field injection — hard to test, hides dependencies
@Service
public class BadService {
@Autowired // avoid
private SomeDep dep;
}
// Setter injection — useful only for optional dependencies
@Service
public class FlexibleService {
private OptionalFeature feature;
@Autowired(required = false)
public void setFeature(OptionalFeature feature) {
this.feature = feature;
}
}
@Qualifier and @Primary
// When multiple beans of the same type exist:
@Service("fastEmailService")
public class FastEmailService implements EmailService { ... }
@Service("reliableEmailService")
public class ReliableEmailService implements EmailService { ... }
// Inject by qualifier name
@Service
public class NotificationService {
public NotificationService(@Qualifier("reliableEmailService") EmailService emailService) {
...
}
}
// Or mark one as primary (default when no qualifier specified)
@Primary
@Service
public class DefaultEmailService implements EmailService { ... }
Bean Scopes
| Scope | Lifecycle | Use case |
singleton | One instance per ApplicationContext (default) | Stateless services, repositories |
prototype | New instance per injection point | Stateful, non-thread-safe objects |
request | One per HTTP request (web only) | Request-scoped state (user context) |
session | One per HTTP session (web only) | Shopping cart, user preferences |
application | One per ServletContext (web only) | App-wide state (rare) |
// Explicit scope declaration
@Scope("prototype") // or use ConfigurableBeanFactory.SCOPE_PROTOTYPE
@Component
public class ReportGenerator {
// New instance created every time this is injected
}
// Web scopes require spring-boot-starter-web
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
@Component
public class RequestContext { ... }
Bean Lifecycle
@Service
public class CacheWarmingService {
private final DataRepository repository;
public CacheWarmingService(DataRepository repository) {
this.repository = repository;
}
// Called after all dependencies are injected
@PostConstruct
public void init() {
log.info("Pre-loading reference data into cache...");
repository.findAllActive().forEach(cache::put);
}
// Called before the bean is destroyed (app shutdown)
@PreDestroy
public void cleanup() {
log.info("Flushing cache to disk...");
cache.flush();
}
}
// Alternative: implement InitializingBean / DisposableBean (less preferred)
// Or use @Bean(initMethod="start", destroyMethod="stop") for third-party classes
Defining Beans via @Configuration
@Configuration
public class InfraConfig {
// @Bean method name is the bean name by default
@Bean
public ObjectMapper objectMapper() {
return JsonMapper.builder()
.addModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
}
@Bean
@ConditionalOnMissingBean // only if user hasn't defined their own
public RestClient restClient(RestClient.Builder builder) {
return builder
.baseUrl("https://api.example.com")
.defaultHeader("Accept", "application/json")
.build();
}
}
5. Web MVC
@RestController Basics
@RestController
@RequestMapping("/api/v1/users") // base path for all methods in class
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
// GET /api/v1/users
@GetMapping
public List<UserDto> listUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return userService.findAll(PageRequest.of(page, size));
}
// GET /api/v1/users/{id}
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// POST /api/v1/users
@PostMapping
public ResponseEntity<UserDto> createUser(
@Valid @RequestBody CreateUserRequest request,
UriComponentsBuilder ucb) {
UserDto created = userService.create(request);
URI location = ucb.path("/api/v1/users/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created);
}
// PUT /api/v1/users/{id}
@PutMapping("/{id}")
public UserDto updateUser(@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
return userService.update(id, request);
}
// PATCH /api/v1/users/{id}/status
@PatchMapping("/{id}/status")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void updateStatus(@PathVariable Long id,
@RequestBody Map<String, String> body) {
userService.updateStatus(id, body.get("status"));
}
// DELETE /api/v1/users/{id}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
userService.delete(id);
}
}
Request Parameter Binding
// Path variable: /users/42
@GetMapping("/{id}")
public User get(@PathVariable Long id) { ... }
// Optional path variable: /items or /items/search
@GetMapping({"", "/search"})
public List<Item> search(@RequestParam(required = false) String q,
@RequestParam(defaultValue = "name") String sortBy) { ... }
// Matrix variables: /items/color=red,green;size=large
@GetMapping("/filter/{attrs}")
public List<Item> filter(@MatrixVariable(pathVar = "attrs") Map<String,List<String>> attrs) { ... }
// Request header
@GetMapping("/protected")
public Data get(@RequestHeader("X-API-Key") String apiKey) { ... }
// Cookie
@GetMapping("/session")
public Info session(@CookieValue("JSESSIONID") String sessionId) { ... }
// Multipart file upload
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public String upload(@RequestParam("file") MultipartFile file) {
// file.getOriginalFilename(), file.getInputStream(), file.getSize()
return "Received: " + file.getOriginalFilename();
}
Content Negotiation
// Produce JSON or XML based on Accept header
@GetMapping(value = "/report",
produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE})
public Report getReport() { ... }
// Consume specific content type
@PostMapping(value = "/events",
consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public void ingestBinary(@RequestBody byte[] payload) { ... }
Exception Handling — @ControllerAdvice
// ProblemDetail is Spring Boot 3's RFC 7807 implementation
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleNotFound(ResourceNotFoundException ex, HttpServletRequest request) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Resource Not Found");
pd.setInstance(URI.create(request.getRequestURI()));
return pd;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
pd.setTitle("Validation Failed");
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(fe -> errors.put(fe.getField(), fe.getDefaultMessage()));
pd.setProperty("errors", errors);
return pd;
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleGeneral(Exception ex, HttpServletRequest request) {
log.error("Unhandled exception for {}", request.getRequestURI(), ex);
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred");
}
}
// Custom exception — Spring maps @ResponseStatus automatically
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) { super(message); }
}
CORS Configuration
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
// Or per-controller with @CrossOrigin
@CrossOrigin(origins = "https://app.example.com", maxAge = 3600)
@RestController
public class PublicController { ... }
6. Spring Data JPA
Entity Definition
@Entity
@Table(name = "orders",
indexes = @Index(columnList = "customer_id, created_at"))
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Many orders belong to one customer
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;
// One order has many line items; cascade persists them together
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status = OrderStatus.PENDING;
@Column(precision = 10, scale = 2, nullable = false)
private BigDecimal totalAmount;
// Auditing fields (populated by Spring Data Auditing)
@CreatedDate
@Column(updatable = false)
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
// Domain method: encapsulate state transitions
public void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("Only PENDING orders can be confirmed");
}
this.status = OrderStatus.CONFIRMED;
}
}
Always use FetchType.LAZY on associations
EAGER fetching loads related entities even when you don't need them. Use
LAZY and explicit
JOIN FETCH in queries when you do need them.
Repository Interface
// JpaRepository<Entity, ID> provides:
// save, saveAll, findById, findAll, existsById, count, delete, deleteById, etc.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Derived query — Spring generates JPQL from method name
List<Order> findByCustomerIdAndStatus(Long customerId, OrderStatus status);
// findBy + And/Or/Between/LessThan/GreaterThan/Like/In/IsNull etc.
List<Order> findByCreatedAtBetween(Instant start, Instant end);
// Count projection
long countByStatus(OrderStatus status);
// Exists projection
boolean existsByCustomerIdAndStatus(Long customerId, OrderStatus status);
// Custom JPQL query
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.customer.id = :customerId " +
"AND o.status = :status ORDER BY o.createdAt DESC")
List<Order> findActiveOrdersWithItems(@Param("customerId") Long customerId,
@Param("status") OrderStatus status);
// Native SQL query (caution: DB-specific)
@Query(value = "SELECT * FROM orders WHERE total_amount > :threshold " +
"AND created_at > NOW() - INTERVAL '30 days'",
nativeQuery = true)
List<Order> findHighValueRecentOrders(@Param("threshold") BigDecimal threshold);
// Modifying query — must be used with @Transactional
@Modifying
@Transactional
@Query("UPDATE Order o SET o.status = :status WHERE o.id IN :ids")
int bulkUpdateStatus(@Param("ids") List<Long> ids, @Param("status") OrderStatus status);
}
Pagination and Sorting
// In controller
@GetMapping
public Page<OrderDto> listOrders(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "desc") String direction) {
Sort sort = direction.equalsIgnoreCase("asc")
? Sort.by(sortBy).ascending()
: Sort.by(sortBy).descending();
Pageable pageable = PageRequest.of(page, size, sort);
return orderRepository.findAll(pageable).map(orderMapper::toDto);
}
// Repository method with Pageable
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
// Slice — like Page but doesn't count total (cheaper for large tables)
Slice<Order> findByCustomerId(Long customerId, Pageable pageable);
Projections
// Interface-based projection — only selected columns fetched
public interface OrderSummary {
Long getId();
OrderStatus getStatus();
BigDecimal getTotalAmount();
// SpEL for computed value
@Value("#{target.customer.email}")
String getCustomerEmail();
}
// Usage in repository
List<OrderSummary> findByCustomerId(Long customerId);
// Class-based (DTO) projection
public record OrderSummaryDto(Long id, OrderStatus status, BigDecimal totalAmount) {}
@Query("SELECT new com.example.dto.OrderSummaryDto(o.id, o.status, o.totalAmount) " +
"FROM Order o WHERE o.customer.id = :cid")
List<OrderSummaryDto> findSummariesByCustomer(@Param("cid") Long customerId);
Transactions
@Service
@Transactional(readOnly = true) // default for all methods — read-only = slight perf boost
public class OrderService {
private final OrderRepository orderRepo;
private final InventoryService inventoryService;
private final EventPublisher eventPublisher;
// Write operations override with readOnly=false
@Transactional // propagation=REQUIRED by default (join existing or create new)
public Order placeOrder(PlaceOrderRequest request) {
Order order = new Order(request.customerId(), request.items());
inventoryService.reserve(request.items()); // part of same transaction
Order saved = orderRepo.save(order);
// Event published after commit (transactional outbox pattern)
eventPublisher.publish(new OrderPlacedEvent(saved.getId()));
return saved;
}
// Requires a NEW transaction — e.g., audit logging that must persist even if parent rolls back
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditOrderAttempt(Long userId, String action) {
auditRepo.save(new AuditEntry(userId, action));
}
// Rollback on any Throwable (default is RuntimeException + Error)
@Transactional(rollbackFor = Exception.class)
public void riskyOperation() throws IOException { ... }
}
Auditing Setup
// Enable in main class or @Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
@SpringBootApplication
public class MyAppApplication { ... }
// Supply current user for @CreatedBy / @LastModifiedBy
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getName);
}
// In entity — mixin via @MappedSuperclass
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {
@CreatedBy @Column(updatable = false) protected String createdBy;
@CreatedDate @Column(updatable = false) protected Instant createdAt;
@LastModifiedBy protected String updatedBy;
@LastModifiedDate protected Instant updatedAt;
}
7. Spring Security Boot 3 style
WebSecurityConfigurerAdapter is REMOVED in Boot 3
In Spring Security 6 (Boot 3), extend
nothing. Instead, declare a
SecurityFilterChain bean. The old
WebSecurityConfigurerAdapter is gone.
SecurityFilterChain Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize, @PostAuthorize, @Secured
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
UserDetailsService userDetailsService) {
this.jwtAuthFilter = jwtAuthFilter;
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
// Stateless REST API — disable CSRF (tokens protect session-based auth, not JWT)
.csrf(AbstractHttpConfigurer::disable)
// Stateless session — do not create HttpSession
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Authorization rules (order matters — first match wins)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/orders/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
// Add JWT filter before Spring's username/password filter
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
// Custom 401/403 handlers
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) ->
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"))
.accessDeniedHandler((req, res, e) ->
res.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"))
)
.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // cost factor 12 (tunable)
}
}
JWT Authentication Filter
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenService tokenService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
String username = tokenService.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (tokenService.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
UserDetailsService Implementation
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
return org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPasswordHash()) // already BCrypt hashed
.roles(user.getRole().name()) // ROLE_ prefix added automatically
.accountExpired(!user.isActive())
.credentialsExpired(false)
.disabled(!user.isEnabled())
.build();
}
}
// Registration — always hash before saving
@Service
public class AuthService {
public User register(RegisterRequest req) {
String hash = passwordEncoder.encode(req.password()); // NEVER store plain text
return userRepo.save(new User(req.email(), hash, Role.USER));
}
}
Method Security
@Service
public class OrderService {
// SpEL expression — only owner or admin can access
@PreAuthorize("authentication.name == #order.customerEmail or hasRole('ADMIN')")
public Order getOrder(Order order) { ... }
// Only for admins
@Secured("ROLE_ADMIN")
public void deleteOrder(Long orderId) { ... }
// Check AFTER method returns — filter return value
@PostAuthorize("returnObject.customerEmail == authentication.name")
public Order findOrder(Long id) { ... }
}
OAuth2 Login (social login via Google/GitHub)
# application.yml
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: openid,email,profile
// In SecurityFilterChain — add:
.oauth2Login(oauth2 -> oauth2
.successHandler(customOAuth2SuccessHandler)
.userInfoEndpoint(ui -> ui.userService(customOAuth2UserService))
)
8. REST API Best Practices
API Versioning Strategies
| Strategy | Example | Pros / Cons |
| URI path |
/api/v1/users |
Simple, cacheable; ties version to URL (most common) |
| Header |
Accept: application/vnd.app.v1+json |
Clean URLs; harder to test in browser |
| Query param |
/users?version=1 |
Simple; pollutes query string, caching issues |
DTO Pattern
// Request DTO — what clients send
public record CreateUserRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8) String password,
@NotBlank String fullName
) {}
// Response DTO — what clients receive (never expose entity directly)
public record UserDto(
Long id,
String email,
String fullName,
String role,
Instant createdAt
) {}
// MapStruct mapper — compile-time, zero reflection
@Mapper(componentModel = "spring")
public interface UserMapper {
UserDto toDto(User user);
User toEntity(CreateUserRequest request);
// Custom mapping
@Mapping(target = "role", expression = "java(user.getRole().name())")
UserDto toDtoWithRole(User user);
}
OpenAPI / Swagger with springdoc-openapi
<!-- pom.xml -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
// Access UI at: http://localhost:8080/swagger-ui.html
// Access spec at: http://localhost:8080/v3/api-docs
@Operation(summary = "Create a new user", description = "Registers a new user account")
@ApiResponse(responseCode = "201", description = "User created",
content = @Content(schema = @Schema(implementation = UserDto.class)))
@ApiResponse(responseCode = "422", description = "Validation error")
@PostMapping
public ResponseEntity<UserDto> createUser(@RequestBody @Valid CreateUserRequest request) {
...
}
// Global API config
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info().title("My API").version("v1.0").description("..."))
.addSecurityItem(new SecurityRequirement().addList("BearerAuth"))
.components(new Components().addSecuritySchemes("BearerAuth",
new SecurityScheme().type(SecurityScheme.Type.HTTP)
.scheme("bearer").bearerFormat("JWT")));
}
}
Pagination Response Envelope
// Standard paginated response structure
public record PagedResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages,
boolean last
) {
public static <T> PagedResponse<T> from(Page<T> page) {
return new PagedResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isLast()
);
}
}
RestClient Boot 3.2
// RestClient replaces RestTemplate (synchronous, fluent API)
// WebClient remains the async/reactive option
@Bean
public RestClient githubClient(RestClient.Builder builder) {
return builder
.baseUrl("https://api.github.com")
.defaultHeader(HttpHeaders.ACCEPT, "application/vnd.github.v3+json")
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + githubToken)
.build();
}
// Usage
List<Repo> repos = githubClient.get()
.uri("/users/{user}/repos?sort=updated", username)
.retrieve()
.body(new ParameterizedTypeReference<List<Repo>>() {});
// HTTP Interface (declarative, like Feign)
@HttpExchange(url = "https://api.github.com", accept = "application/vnd.github.v3+json")
public interface GitHubService {
@GetExchange("/users/{user}/repos")
List<Repo> getRepos(@PathVariable String user);
}
@Bean
GitHubService gitHubService(RestClient.Builder builder) {
RestClient client = builder.build();
RestClientAdapter adapter = RestClientAdapter.create(client);
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
return factory.createClient(GitHubService.class);
}
9. Validation
Built-in Constraints
| Annotation | Applies to | Validates |
@NotNull | Any | Must not be null |
@NotBlank | String | Not null and has non-whitespace chars |
@NotEmpty | String, Collection | Not null and not empty |
@Size(min,max) | String, Collection | Length/size within range |
@Min / @Max | Number | Numeric range |
@Positive / @PositiveOrZero | Number | Sign constraint |
@Email | String | Email format |
@Pattern(regexp) | String | Regex match |
@Past / @Future | Date/time | Temporal constraint |
@Valid | Object | Cascade validation to nested object |
public record CreateProductRequest(
@NotBlank(message = "Name is required")
@Size(max = 200, message = "Name must be 200 chars or fewer")
String name,
@NotNull(message = "Price is required")
@Positive(message = "Price must be positive")
BigDecimal price,
@NotBlank @Pattern(regexp = "^[A-Z]{2,10}$", message = "SKU must be 2-10 uppercase letters")
String sku,
@Valid // cascade to nested object
@NotNull
CategoryRef category,
@Size(max = 10, message = "Maximum 10 tags")
List<@NotBlank String> tags // validate each element
) {}
// Trigger validation in controller
@PostMapping
public ResponseEntity<ProductDto> create(@Valid @RequestBody CreateProductRequest req) {
...
}
// @Validated on service for method-level validation
@Service
@Validated
public class ProductService {
public ProductDto create(@Valid CreateProductRequest req) { ... }
public ProductDto findById(@Positive Long id) { ... }
}
Custom Validator
// Step 1: Define the annotation
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "Email already registered";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// Step 2: Implement the validator
@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository userRepository;
public UniqueEmailValidator(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
if (email == null) return true; // let @NotNull handle null check
return !userRepository.existsByEmail(email);
}
}
// Step 3: Use it
public record RegisterRequest(
@UniqueEmail @Email @NotBlank String email,
@NotBlank @Size(min = 8) String password
) {}
Validation Groups
// Use groups when constraints differ between create and update
public interface OnCreate {}
public interface OnUpdate {}
public class UserRequest {
@Null(groups = OnCreate.class) // id must be null on create
@NotNull(groups = OnUpdate.class) // id must be set on update
Long id;
@NotBlank(groups = {OnCreate.class, OnUpdate.class})
String name;
}
// @Validated(Group.class) instead of @Valid
@PostMapping
public ResponseEntity<UserDto> create(@Validated(OnCreate.class) @RequestBody UserRequest req) {
...
}
@PutMapping("/{id}")
public UserDto update(@PathVariable Long id,
@Validated(OnUpdate.class) @RequestBody UserRequest req) {
...
}
10. Error Handling
Custom Exception Hierarchy
// Base application exception
public abstract class AppException extends RuntimeException {
private final HttpStatus status;
private final String errorCode;
protected AppException(HttpStatus status, String errorCode, String message) {
super(message);
this.status = status;
this.errorCode = errorCode;
}
public HttpStatus getStatus() { return status; }
public String getErrorCode() { return errorCode; }
}
// Concrete exceptions
public class ResourceNotFoundException extends AppException {
public ResourceNotFoundException(String resource, Object id) {
super(HttpStatus.NOT_FOUND, "RESOURCE_NOT_FOUND",
resource + " with id " + id + " not found");
}
}
public class ConflictException extends AppException {
public ConflictException(String message) {
super(HttpStatus.CONFLICT, "CONFLICT", message);
}
}
public class BusinessRuleException extends AppException {
public BusinessRuleException(String rule, String message) {
super(HttpStatus.UNPROCESSABLE_ENTITY, "BUSINESS_RULE_VIOLATION_" + rule, message);
}
}
Global Exception Handler with ProblemDetail Boot 3
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// Handle our custom app exceptions
@ExceptionHandler(AppException.class)
public ProblemDetail handleAppException(AppException ex, HttpServletRequest request) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(ex.getStatus(), ex.getMessage());
pd.setTitle(toTitle(ex.getStatus()));
pd.setInstance(URI.create(request.getRequestURI()));
pd.setProperty("errorCode", ex.getErrorCode());
pd.setProperty("timestamp", Instant.now());
return pd;
}
// Validation errors from @Valid
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
pd.setTitle("Validation Failed");
pd.setDetail("One or more fields failed validation");
Map<String, List<String>> fieldErrors = ex.getBindingResult().getFieldErrors()
.stream()
.collect(Collectors.groupingBy(
FieldError::getField,
Collectors.mapping(FieldError::getDefaultMessage, Collectors.toList())
));
pd.setProperty("fieldErrors", fieldErrors);
return ResponseEntity.unprocessableEntity().body(pd);
}
// Constraint violations from @Validated on service layer
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail handleConstraintViolation(ConstraintViolationException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Constraint Violation");
Map<String, String> violations = ex.getConstraintViolations().stream()
.collect(Collectors.toMap(
v -> v.getPropertyPath().toString(),
v -> v.getMessage()
));
pd.setProperty("violations", violations);
return pd;
}
// DataIntegrityViolationException — e.g., unique constraint from DB
@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail handleDataIntegrity(DataIntegrityViolationException ex) {
log.warn("Data integrity violation: {}", ex.getMostSpecificCause().getMessage());
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
pd.setTitle("Data Conflict");
pd.setDetail("The operation conflicts with existing data");
return pd;
}
// Catch-all — log full stack trace, return generic error
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
log.error("Unexpected error at {}", request.getRequestURI(), ex);
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
pd.setTitle("Internal Server Error");
pd.setDetail("An unexpected error occurred. Please try again later.");
pd.setProperty("requestId", request.getHeader("X-Request-ID"));
return pd;
}
private String toTitle(HttpStatus status) {
return Arrays.stream(status.getReasonPhrase().split(" "))
.map(w -> w.substring(0, 1).toUpperCase() + w.substring(1).toLowerCase())
.collect(Collectors.joining(" "));
}
}
ProblemDetail wire format (RFC 7807)
{
"type": "https://example.com/errors/resource-not-found",
"title": "Not Found",
"status": 404,
"detail": "Order with id 42 not found",
"instance": "/api/v1/orders/42",
"errorCode": "RESOURCE_NOT_FOUND",
"timestamp": "2026-02-22T10:15:30Z"
}
ResponseStatusException — Quick One-off
// Use when you don't want a full custom exception class
@GetMapping("/{id}")
public Product getProduct(@PathVariable Long id) {
return productRepo.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Product " + id + " not found"));
}
11. Testing
Test Slice Overview
| Annotation | Loads | Use for |
@SpringBootTest |
Full ApplicationContext |
Integration tests, end-to-end flows |
@WebMvcTest(Ctrl.class) |
Web layer only (MVC, filters, security) |
Controller logic, request/response mapping |
@DataJpaTest |
JPA layer + in-memory DB |
Repository queries, entity mapping |
@JsonTest |
Jackson ObjectMapper |
JSON serialization/deserialization |
@RestClientTest |
RestClient/RestTemplate + MockServer |
HTTP client integration tests |
| No annotation (plain JUnit) |
Nothing |
Unit tests (services, domain logic) |
Unit Test — Service Layer
// No Spring context — fast, pure JUnit + Mockito
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository orderRepo;
@Mock PaymentService paymentService;
@Mock EmailService emailService;
@InjectMocks OrderService orderService;
@Test
void placeOrder_savesAndReturnsOrder() {
// Arrange
var request = new PlaceOrderRequest(1L, List.of(new OrderItem("SKU-1", 2)));
var savedOrder = new Order(1L, 1L, OrderStatus.PENDING, BigDecimal.valueOf(100));
when(orderRepo.save(any(Order.class))).thenReturn(savedOrder);
// Act
Order result = orderService.placeOrder(request);
// Assert
assertThat(result.getStatus()).isEqualTo(OrderStatus.PENDING);
assertThat(result.getTotalAmount()).isEqualByComparingTo("100");
verify(paymentService).reserve(any());
verify(orderRepo).save(any(Order.class));
}
@Test
void placeOrder_whenInventoryFails_throwsException() {
doThrow(new InsufficientInventoryException("SKU-1"))
.when(paymentService).reserve(any());
assertThatThrownBy(() -> orderService.placeOrder(new PlaceOrderRequest(1L, List.of())))
.isInstanceOf(InsufficientInventoryException.class)
.hasMessageContaining("SKU-1");
verify(orderRepo, never()).save(any()); // assert rollback: nothing persisted
}
}
@WebMvcTest — Controller Layer
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
@MockBean UserService userService; // @MockBean adds mock to Spring context
@Test
void getUser_whenExists_returns200() throws Exception {
var dto = new UserDto(1L, "[email protected]", "Alice", "USER", Instant.now());
when(userService.findById(1L)).thenReturn(Optional.of(dto));
mockMvc.perform(get("/api/v1/users/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("[email protected]"))
.andExpect(jsonPath("$.role").value("USER"));
}
@Test
void getUser_whenNotFound_returns404() throws Exception {
when(userService.findById(99L)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/v1/users/99"))
.andExpect(status().isNotFound());
}
@Test
void createUser_withInvalidEmail_returns422() throws Exception {
var request = new CreateUserRequest("not-an-email", "password123", "Alice");
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.title").value("Validation Failed"))
.andExpect(jsonPath("$.fieldErrors.email").isArray());
}
@Test
@WithMockUser(roles = "ADMIN") // simulate authenticated admin user
void deleteUser_asAdmin_returns204() throws Exception {
mockMvc.perform(delete("/api/v1/users/1"))
.andExpect(status().isNoContent());
verify(userService).delete(1L);
}
}
@DataJpaTest — Repository Layer
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // use real Postgres via Testcontainers
@Transactional // each test rolls back automatically
class OrderRepositoryTest {
@Autowired OrderRepository orderRepo;
@Autowired TestEntityManager em;
@Test
void findByCustomerIdAndStatus_returnsMatchingOrders() {
// Arrange — persist test data within the transaction
Customer customer = em.persist(new Customer("[email protected]"));
em.persist(new Order(customer, OrderStatus.CONFIRMED, BigDecimal.TEN));
em.persist(new Order(customer, OrderStatus.PENDING, BigDecimal.ONE));
em.flush();
// Act
List<Order> result = orderRepo.findByCustomerIdAndStatus(
customer.getId(), OrderStatus.CONFIRMED);
// Assert
assertThat(result).hasSize(1);
assertThat(result.get(0).getStatus()).isEqualTo(OrderStatus.CONFIRMED);
}
}
Testcontainers Integration
// Shared container configuration
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class IntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired TestRestTemplate restTemplate;
@Test
void createAndRetrieveOrder_endToEnd() {
var request = new CreateOrderRequest(1L, List.of());
var created = restTemplate.postForEntity("/api/v1/orders", request, OrderDto.class);
assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
var fetched = restTemplate.getForEntity(
"/api/v1/orders/" + created.getBody().id(), OrderDto.class);
assertThat(fetched.getBody().status()).isEqualTo("PENDING");
}
}
Boot 3.1 Testcontainers support
Spring Boot 3.1+ has first-class Testcontainers support. Declare containers in your
application.yml test config using
spring.datasource.url=jdbc:tc:postgresql:16-alpine:///testdb (TC JDBC URL) for zero-config container startup.
12. Actuator & Monitoring
# application.yml — Actuator configuration
management:
server:
port: 9090 # expose actuator on separate port (good for security)
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,loggers
base-path: /actuator
endpoint:
health:
show-details: when-authorized # hide details from anonymous users
show-components: when-authorized
loggers:
enabled: true
health:
diskspace:
enabled: true
db:
enabled: true
metrics:
export:
prometheus:
enabled: true # exposes /actuator/prometheus for Prometheus scraping
tags:
application: ${spring.application.name}
environment: ${spring.profiles.active:default}
info:
env:
enabled: true
git:
enabled: true
mode: full
# Info endpoint data
info:
app:
name: My App
version: '@project.version@' # Maven property substitution
description: Production-ready Spring Boot service
Custom Health Indicator
@Component
public class ExternalServiceHealthIndicator implements HealthIndicator {
private final ExternalServiceClient client;
@Override
public Health health() {
try {
boolean isUp = client.ping();
if (isUp) {
return Health.up()
.withDetail("service", "external-api")
.withDetail("responseTime", "42ms")
.build();
}
return Health.down()
.withDetail("service", "external-api")
.withDetail("reason", "Ping returned false")
.build();
} catch (Exception ex) {
return Health.down(ex)
.withDetail("service", "external-api")
.build();
}
}
}
Custom Metrics with Micrometer
@Service
public class OrderService {
private final MeterRegistry meterRegistry;
private final Counter ordersPlaced;
private final Timer orderProcessingTimer;
public OrderService(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.ordersPlaced = Counter.builder("orders.placed")
.description("Total orders placed")
.tag("region", "us-east")
.register(meterRegistry);
this.orderProcessingTimer = Timer.builder("orders.processing.time")
.description("Time to process an order")
.register(meterRegistry);
}
public Order placeOrder(PlaceOrderRequest request) {
return orderProcessingTimer.record(() -> {
Order order = doPlaceOrder(request);
ordersPlaced.increment();
meterRegistry.gauge("orders.queue.size", orderQueue.size());
return order;
});
}
}
Kubernetes Probes
# Kubernetes deployment probes powered by actuator
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 9090
initialDelaySeconds: 10
periodSeconds: 5
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 9090
initialDelaySeconds: 20
periodSeconds: 10
# Enable Kubernetes probes in application.yml
management:
endpoint:
health:
probes:
enabled: true
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
13. Caching
// Step 1: Enable caching
@SpringBootApplication
@EnableCaching
public class MyAppApplication { ... }
// Step 2: Declare cache manager (Caffeine for local, Redis for distributed)
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager mgr = new CaffeineCacheManager();
mgr.setCaffeine(Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats());
return mgr;
}
}
// Step 3: Annotate methods
@Service
public class ProductService {
// Cache result; key = method argument
@Cacheable(value = "products", key = "#id")
public ProductDto findById(Long id) {
// Only called on cache miss
return productRepo.findById(id).map(mapper::toDto)
.orElseThrow(() -> new ResourceNotFoundException("Product", id));
}
// Cache with condition (only cache if price > 0)
@Cacheable(value = "products", key = "#sku", condition = "#result != null")
public ProductDto findBySku(String sku) { ... }
// Update cache on save
@CachePut(value = "products", key = "#result.id")
@Transactional
public ProductDto update(Long id, UpdateProductRequest req) {
Product product = productRepo.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product", id));
mapper.update(product, req);
return mapper.toDto(productRepo.save(product));
}
// Evict from cache on delete
@CacheEvict(value = "products", key = "#id")
@Transactional
public void delete(Long id) {
productRepo.deleteById(id);
}
// Evict all entries in a cache (e.g., after bulk import)
@CacheEvict(value = "products", allEntries = true)
public void refreshAll() { ... }
}
Redis Cache Configuration
# application.yml
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: 6379
password: ${REDIS_PASSWORD:}
cache:
type: redis
redis:
time-to-live: 600000 # 10 minutes in ms
// Per-cache TTL with Redis
@Bean
public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
return builder -> builder
.withCacheConfiguration("products",
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer())))
.withCacheConfiguration("sessions",
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1)));
}
Cache pitfalls
- Cache keys must be unique across methods — use
value (cache name) + key (SpEL)
- Don't cache mutable objects — cache the DTO/record, not the entity
- Self-invocation bypasses AOP — calling
findById() from within the same bean skips the cache
- Serialization: cached objects must be
Serializable (or use JSON serializer for Redis)
14. Messaging
Spring Kafka
# application.yml
spring:
kafka:
bootstrap-servers: ${KAFKA_BROKERS:localhost:9092}
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
acks: all # strongest durability guarantee
retries: 3
properties:
enable.idempotence: true
consumer:
group-id: order-service
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
auto-offset-reset: earliest
properties:
spring.json.trusted.packages: "com.example.events"
listener:
ack-mode: MANUAL_IMMEDIATE # explicit ack for at-least-once
// Producer
@Service
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate;
@Transactional // transactional outbox: message sent in same DB transaction
public void publish(OrderPlacedEvent event) {
kafkaTemplate.send("orders.placed", event.orderId().toString(), event)
.whenComplete((result, ex) -> {
if (ex != null) {
log.error("Failed to publish OrderPlacedEvent for order {}",
event.orderId(), ex);
} else {
log.debug("Published to partition {} offset {}",
result.getRecordMetadata().partition(),
result.getRecordMetadata().offset());
}
});
}
}
// Consumer
@Component
public class OrderEventConsumer {
@KafkaListener(topics = "orders.placed", groupId = "notification-service",
containerFactory = "kafkaListenerContainerFactory")
public void handleOrderPlaced(
@Payload OrderPlacedEvent event,
@Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
@Header(KafkaHeaders.OFFSET) long offset,
Acknowledgment ack) {
try {
log.info("Processing order {} from partition {} offset {}",
event.orderId(), partition, offset);
notificationService.sendOrderConfirmation(event);
ack.acknowledge(); // commit offset only after successful processing
} catch (RecoverableException ex) {
log.warn("Recoverable error — will retry", ex);
// Don't ack — Kafka will redeliver
}
}
// Dead letter topic handling
@KafkaListener(topics = "orders.placed.DLT")
public void handleDlt(@Payload byte[] payload, @Header KafkaHeaders.EXCEPTION_MESSAGE String msg) {
log.error("DLT message: {}", msg);
alertingService.sendAlert("Kafka DLT message received");
}
}
@Scheduled Tasks
@Configuration
@EnableScheduling
public class SchedulingConfig { }
@Component
public class MaintenanceTasks {
// Fixed delay: wait 5 min AFTER previous execution completes
@Scheduled(fixedDelay = 300_000, initialDelay = 60_000)
public void cleanupExpiredTokens() {
int deleted = tokenRepo.deleteExpiredBefore(Instant.now());
log.info("Cleaned up {} expired tokens", deleted);
}
// Cron: every day at 2:00 AM
@Scheduled(cron = "0 0 2 * * *", zone = "America/New_York")
public void generateDailyReport() {
reportService.generateAndEmail();
}
// Fixed rate: every 30 seconds regardless of execution time (concurrent risk!)
@Scheduled(fixedRate = 30_000)
public void pollExternalApi() {
integrationService.sync();
}
}
@Async — Non-blocking Execution
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
@Service
public class EmailService {
// Returns immediately; email sent in background thread
@Async
public CompletableFuture<Void> sendWelcomeEmail(String email) {
// runs in async executor thread pool
mailer.send(email, "Welcome!", buildWelcomeTemplate(email));
return CompletableFuture.completedFuture(null);
}
}
// Caller
orderService.placeOrder(request);
emailService.sendWelcomeEmail(user.getEmail()); // fires and continues
15. Reactive Spring
Spring WebFlux is Spring's reactive web framework built on Project Reactor. It uses non-blocking I/O on Netty (or Undertow) and models everything as streams: Mono<T> (0..1 items) and Flux<T> (0..N items).
When to Choose Reactive vs Servlet
| Factor | Servlet (MVC) | Reactive (WebFlux) |
| Programming model | Imperative, familiar | Functional/reactive, steeper curve |
| Concurrency | Thread-per-request | Small event-loop thread pool |
| I/O bound workloads | Needs large thread pool | Highly efficient with few threads |
| CPU bound workloads | Fine | No advantage (may be slower) |
| Spring Data JPA | Full support | Requires R2DBC for non-blocking DB |
| Streaming responses | Manual | First-class (SSE, WebSocket) |
| Ecosystem maturity | Very mature, huge ecosystem | Mature but smaller ecosystem |
Recommendation
Default to Spring MVC unless you have high-concurrency I/O needs or are building a streaming API. WebFlux shines for API gateways, real-time feeds, and services with many concurrent long-lived connections.
Reactive Controller
@RestController
@RequestMapping("/api/v1/products")
public class ReactiveProductController {
private final ReactiveProductRepository productRepo;
private final ReactiveUserService userService;
// Returns Flux — streams a collection (JSON array or SSE)
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public Flux<ProductDto> listProducts() {
return productRepo.findAll()
.map(mapper::toDto)
.onErrorMap(ex -> new ServerWebInputException("Failed to fetch products"));
}
// Returns Mono — zero or one item
@GetMapping("/{id}")
public Mono<ResponseEntity<ProductDto>> getProduct(@PathVariable Long id) {
return productRepo.findById(id)
.map(p -> ResponseEntity.ok(mapper.toDto(p)))
.defaultIfEmpty(ResponseEntity.notFound().build());
}
// Server-Sent Events — stream data to browser in real time
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ProductUpdateEvent> streamUpdates() {
return productUpdateSink.asFlux() // e.g., Sinks.Many hotFlux
.onBackpressureBuffer(100);
}
}
WebClient (Non-blocking HTTP Client)
@Bean
public WebClient webClient(WebClient.Builder builder) {
return builder
.baseUrl("https://api.external.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.filter(ExchangeFilterFunctions.basicAuthentication("user", "pass"))
.codecs(c -> c.defaultCodecs().maxInMemorySize(1024 * 1024)) // 1MB response limit
.build();
}
// Usage — reactive chain
public Mono<PaymentResult> processPayment(PaymentRequest request) {
return webClient.post()
.uri("/payments")
.bodyValue(request)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, resp -> resp.bodyToMono(String.class)
.map(body -> new PaymentException("Client error: " + body)))
.onStatus(HttpStatusCode::is5xxServerError, resp ->
Mono.error(new ServiceUnavailableException("Payment service unavailable")))
.bodyToMono(PaymentResult.class)
.timeout(Duration.ofSeconds(10))
.retryWhen(Retry.backoff(3, Duration.ofMillis(500))
.filter(ex -> ex instanceof ServiceUnavailableException));
}
R2DBC — Reactive Database
spring:
r2dbc:
url: r2dbc:postgresql://localhost:5432/mydb
username: ${DB_USER}
password: ${DB_PASS}
sql:
init:
mode: always
// Reactive repository
public interface ReactiveProductRepository
extends ReactiveCrudRepository<Product, Long> {
Flux<Product> findByCategory(String category);
Mono<Product> findBySku(String sku);
Mono<Long> countByStatus(ProductStatus status);
}
16. Deployment
Multi-stage Dockerfile
# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /workspace
# Cache Maven dependencies (only invalidated when pom.xml changes)
COPY pom.xml .
COPY .mvn/ .mvn/
COPY mvnw .
RUN ./mvnw dependency:resolve -q
# Build the application
COPY src/ src/
RUN ./mvnw package -DskipTests -q
# Extract layered JAR for optimal Docker caching
RUN java -Djarmode=layertools -jar target/*.jar extract
# Stage 2: Runtime (minimal JRE image)
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# Create non-root user for security
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
# Copy layers in dependency-frequency order (least changed first)
COPY --from=builder /workspace/dependencies/ ./
COPY --from=builder /workspace/spring-boot-loader/ ./
COPY --from=builder /workspace/snapshot-dependencies/ ./
COPY --from=builder /workspace/application/ ./
# Health check
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8080/actuator/health | grep UP || exit 1
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-Djava.security.egd=file:/dev/./urandom", \
"org.springframework.boot.loader.launch.JarLauncher"]
Buildpacks (No Dockerfile needed)
# Maven: builds OCI image using Cloud Native Buildpacks
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myapp:latest
# Gradle
./gradlew bootBuildImage --imageName=myapp:latest
# Push and run
docker push myapp:latest
docker run -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=prod \
-e DB_URL=jdbc:postgresql://db:5432/mydb \
-e DB_USER=app -e DB_PASS=secret \
myapp:latest
GraalVM Native Image Boot 3
# Requires GraalVM JDK with native-image tool
sdk install java 21.0.2-graal # via SDKMAN
# Compile (takes 2-5 minutes, produces small fast binary)
./mvnw native:compile -Pnative
# Run (starts in ~50ms vs ~3s for JVM)
./target/my-app
# Docker image with native binary
./mvnw spring-boot:build-image -Pnative
Native image limitations
- Reflection, proxies, and serialization need AOT hints (usually auto-generated by Boot 3)
- Libraries using dynamic class loading may need workarounds
- Compilation is slow — not suitable for development inner loop
- No JIT optimization at runtime — throughput may be lower under sustained load
Graceful Shutdown
# application.yml
server:
shutdown: graceful # wait for in-flight requests to complete
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # max wait time
# Kubernetes: SIGTERM triggers graceful shutdown
# Pod spec should have terminationGracePeriodSeconds > spring.lifecycle.timeout
terminationGracePeriodSeconds: 60
17. Common Annotations Reference
Core / IoC
| Annotation | Package | Purpose |
@SpringBootApplication | spring.boot | Combines @Configuration + @EnableAutoConfiguration + @ComponentScan |
@Component | spring.stereotype | Generic Spring-managed bean |
@Service | spring.stereotype | Business logic bean (semantic marker) |
@Repository | spring.stereotype | Data access bean + exception translation |
@Configuration | spring.context | Class with @Bean factory methods |
@Bean | spring.context | Factory method producing a Spring bean |
@Autowired | spring.beans | Dependency injection (optional with single constructor) |
@Qualifier | spring.beans | Disambiguate multiple beans of same type |
@Primary | spring.context | Mark preferred bean when multiple candidates |
@Scope | spring.context | Bean scope (singleton/prototype/request/session) |
@Lazy | spring.context | Initialize bean only when first needed |
@PostConstruct | jakarta.annotation | Called after all deps injected |
@PreDestroy | jakarta.annotation | Called before bean destroyed |
@Profile | spring.context | Activate bean/config only for specified profile(s) |
@Conditional | spring.context | Register bean based on custom condition |
@ConditionalOnProperty | spring.boot | Register bean based on property value |
@ConditionalOnMissingBean | spring.boot | Register bean only if no bean of that type exists |
@Value | spring.beans | Inject property or SpEL expression |
@ConfigurationProperties | spring.boot | Bind property prefix to typed object |
@EnableConfigurationProperties | spring.boot | Enable @ConfigurationProperties class(es) |
Web MVC
| Annotation | Purpose |
@RestController | @Controller + @ResponseBody for REST APIs |
@RequestMapping | Map URL patterns to class or method |
@GetMapping / @PostMapping / @PutMapping / @PatchMapping / @DeleteMapping | HTTP method-specific shortcuts |
@PathVariable | Bind URI template variable |
@RequestParam | Bind query/form parameter |
@RequestBody | Deserialize request body (JSON → object) |
@ResponseBody | Serialize return value to response body |
@ResponseStatus | Set default HTTP status code |
@RequestHeader | Bind HTTP header value |
@CookieValue | Bind cookie value |
@CrossOrigin | Configure CORS for class or method |
@ExceptionHandler | Handle specific exception types in controller |
@ControllerAdvice / @RestControllerAdvice | Global cross-cutting concern for controllers |
@Valid / @Validated | Trigger Bean Validation on argument |
Spring Data JPA
| Annotation | Purpose |
@Entity | Mark class as JPA entity |
@Table | Customize table name, indexes, unique constraints |
@Id | Primary key field |
@GeneratedValue | ID generation strategy (IDENTITY, SEQUENCE, UUID) |
@Column | Customize column name, nullable, length, precision |
@ManyToOne / @OneToMany / @OneToOne / @ManyToMany | Association mappings |
@JoinColumn | Customize FK column |
@Enumerated(EnumType.STRING) | Store enum as its string name |
@Transient | Exclude field from persistence |
@MappedSuperclass | Base class for shared fields (not an entity itself) |
@Transactional | Declarative transaction management |
@Query | Custom JPQL or native SQL query |
@Modifying | Marks a @Query as DML (UPDATE/DELETE) |
@CreatedDate / @LastModifiedDate | Auto-populate audit timestamps |
@CreatedBy / @LastModifiedBy | Auto-populate audit user fields |
Security
| Annotation | Purpose |
@EnableWebSecurity | Activate Spring Security web support |
@EnableMethodSecurity | Enable method-level security annotations |
@PreAuthorize(expr) | Check before method executes (SpEL) |
@PostAuthorize(expr) | Check after method returns |
@Secured("ROLE_X") | Simple role-based access check |
@WithMockUser | Test: simulate authenticated user in @WebMvcTest |
Testing
| Annotation | Purpose |
@SpringBootTest | Full application context for integration tests |
@WebMvcTest(Class) | Web layer slice test |
@DataJpaTest | JPA slice test with embedded DB |
@MockBean | Add Mockito mock to Spring context |
@SpyBean | Add Mockito spy wrapping real bean |
@TestConfiguration | Additional config only for tests |
@ActiveProfiles | Activate profiles in test |
@DynamicPropertySource | Override properties in test (e.g., Testcontainers URLs) |
@Testcontainers | Manage Testcontainers container lifecycle |
@Container | Mark field as a Testcontainer |
18. Common Pitfalls
1. @Transactional on Private Methods
@Transactional is proxy-based — private methods are NOT intercepted
Spring uses CGLIB/JDK proxies. Calling a
@Transactional private method bypasses the proxy entirely. Transactions silently don't work.
// WRONG: @Transactional on private — transaction never starts
@Service
public class OrderService {
@Transactional // IGNORED — proxy can't intercept private
private void saveInternal(Order order) { ... }
}
// RIGHT: make it package-private or public, or refactor to a separate bean
@Service
public class OrderService {
@Transactional // works — proxy can intercept
public void save(Order order) { ... }
}
2. Self-Invocation Bypasses AOP
// WRONG: calling @Transactional method from within same class bypasses proxy
@Service
public class BadService {
public void doSomething() {
this.transactionalMethod(); // calls the actual object, NOT the proxy!
}
@Transactional
public void transactionalMethod() { ... } // transaction NEVER starts
}
// RIGHT: inject self or extract to separate bean
@Service
public class GoodService {
private final GoodService self; // inject proxy of self
public GoodService(@Lazy GoodService self) { this.self = self; }
public void doSomething() {
self.transactionalMethod(); // goes through proxy ✓
}
@Transactional
public void transactionalMethod() { ... }
}
3. N+1 Query Problem
// WRONG: N+1 — loads all orders, then 1 query per order for items
List<Order> orders = orderRepo.findAll();
orders.forEach(o -> o.getItems().size()); // LAZY load triggers N queries!
// RIGHT option A: JOIN FETCH in query
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
List<Order> findWithItems(@Param("status") OrderStatus status);
// RIGHT option B: EntityGraph
@EntityGraph(attributePaths = {"items", "customer"})
List<Order> findByStatus(OrderStatus status);
// RIGHT option C: @BatchSize on collection (reduce N+1 to ceil(N/batch) queries)
@OneToMany(mappedBy = "order")
@BatchSize(size = 20)
private List<OrderItem> items;
4. Circular Dependencies
// Spring Boot 2.6+ fails fast on circular dependencies (previously worked silently)
// ERROR: The dependencies of some of the beans in the application context form a cycle:
// serviceA → serviceB → serviceA
// Solution 1: Refactor — extract shared logic to a third bean
// Solution 2: @Lazy on one injection point
@Service
public class ServiceA {
public ServiceA(@Lazy ServiceB serviceB) { ... } // breaks the cycle
}
// Solution 3: Use setter injection for one of the dependencies
// Solution 4: Use ApplicationContext.getBean() lazily (avoid — defeats DI benefits)
5. Missing @ComponentScan Coverage
// WRONG: Main class in wrong package; sibling packages not scanned
// com.company.Main <-- scans com.company.*
// com.company.orders.* <-- found ✓
// com.external.plugin.* <-- NOT found ✗
// Fix: explicit scanBasePackages or move main class up
@SpringBootApplication(scanBasePackages = {"com.company", "com.external.plugin"})
public class Main { ... }
6. RestTemplate is Deprecated — Use RestClient or WebClient
RestTemplate is in maintenance mode
- Use
RestClient (Boot 3.2+) for synchronous HTTP — same fluent API, actively developed
- Use
WebClient for async/reactive HTTP
- Use HTTP Interface (
@HttpExchange) for declarative clients
7. Security Misconfiguration
// WRONG: overly permissive catch-all
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").permitAll() // permits EVERYTHING under /api!
.anyRequest().authenticated()
)
// WRONG: permitAll on all requests — effectively disables security
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
// RIGHT: explicit allowlist, authenticated by default
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/register").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated() // everything else requires auth
)
8. Entity Exposed Directly from Controller
// WRONG: exposing entity — leaks DB schema, causes Jackson lazy-load issues
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userRepo.findById(id).orElseThrow(...); // exposes passwordHash, internal fields!
}
// RIGHT: always map to DTO before returning
@GetMapping("/{id}")
public UserDto getUser(@PathVariable Long id) {
return userRepo.findById(id)
.map(userMapper::toDto)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
}
9. JPA Open Session in View (OSIV)
# OSIV is enabled by default in Boot — keeps the EntityManager open during view rendering
# This causes lazy-load to work in controllers/views but at the cost of DB connections held longer
# For REST APIs, disable it:
spring:
jpa:
open-in-view: false # disable OSIV for APIs
OSIV gotcha
With OSIV disabled, any lazy-loaded association accessed outside
@Transactional throws
LazyInitializationException. This is actually
good — it forces you to think about what data you need and fetch it explicitly.
10. Bean Scope Mismatch
// WRONG: injecting prototype bean into singleton — same instance every time!
@Service // singleton by default
public class ReportService {
@Autowired
private ReportGenerator generator; // prototype, but only ONE is ever created!
}
// RIGHT: use ApplicationContext or ObjectFactory to get new prototype each time
@Service
public class ReportService {
private final ObjectFactory<ReportGenerator> generatorFactory;
public ReportService(ObjectFactory<ReportGenerator> generatorFactory) {
this.generatorFactory = generatorFactory;
}
public Report generate() {
ReportGenerator generator = generatorFactory.getObject(); // fresh instance
return generator.generate();
}
}
11. Profile Confusion
# application.yml — always loaded regardless of profile
# application-dev.yml — loaded ADDITIONALLY when profile=dev
# application-prod.yml — loaded ADDITIONALLY when profile=prod
# application-prod.yml ADDS TO / OVERRIDES application.yml
# It does NOT replace it — both are active when profile=prod
# Common mistake: forgetting to set profile in prod; defaults to no profile
# Fix: always set SPRING_PROFILES_ACTIVE in deployment config
export SPRING_PROFILES_ACTIVE=prod
java -jar app.jar
# Or in Kubernetes:
env:
- name: SPRING_PROFILES_ACTIVE
value: prod
12. Jackson Serialization Gotchas
// Bidirectional relationships cause infinite recursion in JSON
@Entity
public class Order {
@OneToMany(mappedBy = "order")
@JsonManagedReference // serialized normally
private List<OrderItem> items;
}
@Entity
public class OrderItem {
@ManyToOne
@JsonBackReference // NOT serialized (back-reference)
private Order order;
}
// Better: don't serialize entities directly. Use DTOs.
// Java records + Jackson: records work out of the box in Boot 3 (Jackson 2.12+)
public record UserDto(Long id, String email, Instant createdAt) {}
// Serializes to: {"id":1,"email":"[email protected]","createdAt":"2026-01-15T10:00:00Z"}