Everything from this track on one page, grouped by what you are trying to do rather than by topic. Written to be scanned. Each section links to the lesson behind it.
All of it is Spring Boot 4.1 on Java 21. Where Boot 3 differs, it is marked ⚠️.
Start a project
curl https://start.spring.io/starter.zip \
-d type=maven-project -d language=java \
-d bootVersion=4.1.0 -d javaVersion=21 \
-d dependencies=web,data-jpa,validation,lombok \
-o demo.zip && unzip demo.zip -d demo./mvnw spring-boot:run # run
./mvnw spring-boot:run -Dspring-boot.run.arguments=--debug # + condition report
./mvnw test # test
./mvnw clean compile # when behaviour ≠ source
./mvnw spotless:apply # format⚠️ Boot 3 → Boot 4 renames
| Boot 3 | Boot 4 / Framework 7 |
|---|---|
spring-boot-starter-web | spring-boot-starter-webmvc |
spring-boot-starter-aop | spring-boot-starter-aspectj |
spring-boot-starter-test | spring-boot-starter-webmvc-test |
liquibase-core | spring-boot-starter-liquibase |
boot.autoconfigure.jms.* | boot.jms.autoconfigure.* |
MappingJackson2MessageConverter | JacksonJsonMessageConverter |
spring-retry + @EnableRetry | built in +
@EnableResilientMethods |
@MockBean | @MockitoBean |
| Java 17 | Java 21 |
Wire a bean
@Service @Repository @RestController @Component // your classes
@Configuration + @Bean // someone else's classes
// Constructor injection — the default. No @Autowired needed.
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final ProductDAO productDAO;
private final PizzaProperties properties;
}
// Optional dependency — degrades instead of failing startup
private final ObjectProvider<JavaMailSender> senderProvider;
JavaMailSender sender = senderProvider.getIfAvailable(); // null if absent
@Primary // the default when two beans fit
@Qualifier("name") // pick one explicitly; beats @Primary
@Scope("prototype") // new instance per injection point
@PostConstruct void init() { } // after injection — validate config here
@PreDestroy void close() { } // on graceful shutdownConfigure it
@Validated
@ConfigurationProperties(prefix = "pizza")
public record PizzaProperties(@Valid Jwt jwt, @Valid Pricing pricing) {
public record Jwt(@NotBlank @Size(min = 32) String secret, @Positive long expirationMinutes) {}
public record Pricing(@DecimalMin("0.0") @DecimalMax("1.0") BigDecimal taxRate) {}
}
@SpringBootApplication
@ConfigurationPropertiesScan // finds them all
public class Application { }pizza.jwt.secret=${JWT_SECRET:} # env var with a default after the colon
spring.profiles.active=local # profile → application-local.propertiesPrecedence, lowest first: jar properties → profile → env vars → -D → command line.
Expose an endpoint
@RestController
@RequestMapping("/api/products")
public class ProductRestController {
@GetMapping public List<ProductDTO> list(
@RequestParam(required = false) ProductType type) { }
@GetMapping("/{id}") public ProductDTO get(@PathVariable UUID id) { }
@PostMapping public ResponseEntity<ProductDTO> create(
@Valid @RequestBody ProductCreateDTO dto) { }
@PutMapping("/{id}") // replace
@PatchMapping("/{id}/deactivate") // partial state change
@DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable UUID id) {
return ResponseEntity.noContent().build(); // 204
}
}@RequestParam(defaultValue = "0") int page
@RequestHeader("Stripe-Signature") String signature
@RequestPart("file") MultipartFile file // + consumes = MULTIPART_FORM_DATA_VALUE
Authentication authentication // the current caller200 ok · 201 created · 204 no content · 400 your fault · 401 who are you · 403 no · 404 not there · 409 conflict · 500 our fault.
Handle errors
@RestControllerAdvice
public class RestExceptionHandler {
@ExceptionHandler(ApiException.class) // yours
@ExceptionHandler(MethodArgumentNotValidException.class) // @Valid failed
@ExceptionHandler(HttpMessageNotReadableException.class) // unparseable body → 400
@ExceptionHandler(MethodArgumentTypeMismatchException.class) // bad path var → 400
@ExceptionHandler(AccessDeniedException.class) // → 403
@ExceptionHandler(Exception.class) // catch-all → 500, log only
}
throw ApiException.notFound("Product", id);
throw ApiException.badRequest("An email address is required");⚠️ Filter-level security errors bypass this — configure
authenticationEntryPoint / accessDeniedHandler instead.
Query a database
@Entity
@SQLRestriction("deleted = false") // soft delete, JPA reads only
public class Product {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@JdbcTypeCode(SqlTypes.CHAR) private UUID publicId; // MySQL: else BINARY(16)
@Enumerated(EnumType.STRING) private ProductType type; // NEVER ordinal
@CreationTimestamp private LocalDateTime createdAt;
@UpdateTimestamp private LocalDateTime updatedAt;
@ToString.Exclude @EqualsAndHashCode.Exclude
@OneToMany(mappedBy = "product", cascade = ALL, orphanRemoval = true, fetch = LAZY)
private List<ProductSize> sizes = new ArrayList<>();
}// Derived query
Optional<User> findByEmailIgnoreCase(String email);
boolean existsByEmail(String email);
// JOIN FETCH kills N+1
@Query("SELECT DISTINCT p FROM Product p LEFT JOIN FETCH p.sizes WHERE p.publicId = :id")
Optional<Product> findByPublicIdWithSizes(@Param("id") UUID id);
// JdbcTemplate for aggregates
String query = """
SELECT COUNT(*) AS total_orders, COALESCE(SUM(o.total), 0) AS total_revenue
FROM customer_order o
WHERE o.deleted = 0 AND o.created_at >= :from
""";
jdbcTemplate.queryForObject(query, Map.of("from", from), summaryRowMapper);spring.jpa.hibernate.ddl-auto=validate # never `update`
spring.jpa.open-in-view=false # surfaces N+1 in development
spring.jpa.show-sql=true⚠️ Hand-written SQL must filter deleted = 0 itself.
@SQLRestriction only applies to queries Hibernate builds.
Secure it
@Configuration @EnableWebSecurity @EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable()) // ONLY safe for header-based tokens
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers(HttpMethod.GET, "/api/orders/mine").authenticated() // specific
.requestMatchers(HttpMethod.GET, "/api/orders/*").permitAll() // general
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) // default deny
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
}@PreAuthorize("hasRole('ADMIN')") // = authority ROLE_ADMIN
@PreAuthorize("#email == authentication.name")
@PreAuthorize("@orderSecurity.isOwner(#id, authentication.name)")First match wins — specific rules before general ones.
Cross-cutting concerns
// AOP
@Aspect @Component
@Pointcut("execution(* com.pizza.api.entity..*ServiceImpl.*(..))")
@Around("serviceLayer()") public Object time(ProceedingJoinPoint jp) throws Throwable {
try { return jp.proceed(); } finally { /* … */ }
}
// Events
events.publishEvent(OrderPlacedEvent.from(saved));
@EventListener // inside the transaction
@TransactionalEventListener(phase = AFTER_COMMIT) // after commit — use this
@Order(10) // sequence listeners
// Caching
@EnableCaching
@Cacheable(value = "menu", key = "#type")
@Caching(evict = { @CacheEvict(value = "menu", allEntries = true) }) // EVERY write path
// Async + scheduling
@EnableAsync @EnableScheduling
@Bean(name = "taskExecutor") // the name matters
@Async @Scheduled(cron = "0 0 3 * * *") @Scheduled(fixedDelay = 60000)
// Retry — Framework 7, no dependency
@EnableResilientMethods
@Retryable(includes = {ApiConnectionException.class}, maxRetries = 3,
delay = 200, multiplier = 2.0, maxDelay = 2000, jitter = 100)⚠️ The proxy rule
Lesson 8 — the most useful thing on this page.
public void a() { b(); } // b()'s annotations DO NOT RUN — self-invocation, no proxy
@Transactional public void b() { }Applies identically to @Transactional, @Cacheable,
@Async, @Retryable, @PreAuthorize and every
@Aspect. All fail silently. Also: private and
final methods are never advised.
Map and reduce boilerplate
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface EntityDTOMapper {
@Mapping(target = "id", source = "publicId") // never expose the BIGINT
ProductDTO mapProductToProductDTO(Product product);
@Mapping(target = "id", ignore = true) // mass-assignment guard
@Mapping(target = "deleted", ignore = true)
Product mapProductCreateDTOToProduct(ProductCreateDTO dto);
}⚠️ Processor order: lombok → lombok-mapstruct-binding → mapstruct-processor. Wrong order compiles and maps nothing.
@Slf4j // log.info("Created {}", id)
@RequiredArgsConstructor // constructor from final fields
@Builder @Builder.Default // Default or initialisers are IGNORED
@Getter @Setter
@ToString.Exclude // on every secret and every collection
// NEVER @Data on a JPA entityTest it
@WebMvcTest(ProductRestController.class) // web layer only
@DataJpaTest // JPA only, rolled back
@SpringBootTest @AutoConfigureMockMvc // everything
@Transactional // roll back after each test
@MockitoBean private ProductService service;
@WithMockUser(roles = "ADMIN")
mockMvc.perform(get("/api/products"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Pepperoni"));
assertThat(priced.total()).isEqualByComparingTo("22.42"); // NOT isEqualTo for BigDecimalTen things that fail silently
- Self-invocation — every proxy annotation stops working.
- Missing
@EnableCaching/@EnableAsync/@EnableMethodSecurity— annotations inert. @Validmissing on the parameter — no validation runs.- MapStruct processor order — mappers map nothing.
- Hand-written SQL and soft deletes — deleted rows counted.
- A cache name typo — reads and evictions use different caches.
- Executor bean not named
taskExecutor— unbounded default pool. @EnumType.ORDINAL(the default) — reordering the enum corrupts every row.@Builderwithout@Builder.Default— initialisers ignored.- Missing autoconfiguration in Boot 4 — fails far from the cause. Use
--debug.
Next: interview questions.