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+ namespacejavax.*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:

StarterBrings in
spring-boot-starter-webTomcat, Spring MVC, Jackson
spring-boot-starter-data-jpaHibernate, Spring Data JPA, HikariCP
spring-boot-starter-securitySpring Security, crypto
spring-boot-starter-actuatorMicrometer, health/metrics endpoints
spring-boot-starter-testJUnit 5, Mockito, AssertJ, MockMvc
spring-boot-starter-webfluxReactor, WebFlux, Netty
spring-boot-starter-validationHibernate Validator (Jakarta Bean Validation)
spring-boot-starter-cacheSpring Cache abstraction
spring-boot-starter-amqpSpring AMQP, RabbitMQ client
spring-boot-starter-kafkaSpring 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)

  1. Command-line arguments (--key=value)
  2. SPRING_APPLICATION_JSON environment variable
  3. OS environment variables
  4. application-{profile}.yml outside the JAR
  5. application.yml outside the JAR
  6. application-{profile}.yml inside the JAR (classpath)
  7. application.yml inside the JAR (classpath)
  8. @PropertySource annotated classes
  9. Default properties (SpringApplication.setDefaultProperties)
12-factor config
For production, set secrets via OS environment variables (never commit them). Spring Boot maps SERVER_PORTserver.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

AnnotationPurposeSpecial behavior
@ComponentGeneric managed beanBase of all stereotypes
@ServiceBusiness logic layerNone (semantic marker)
@RepositoryData access layerTranslates DB exceptions to DataAccessException
@ControllerWeb MVC controllerReturns view names
@RestControllerREST API controller@Controller + @ResponseBody
@ConfigurationConfig class with @Bean methodsSubclassed 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

ScopeLifecycleUse case
singletonOne instance per ApplicationContext (default)Stateless services, repositories
prototypeNew instance per injection pointStateful, non-thread-safe objects
requestOne per HTTP request (web only)Request-scoped state (user context)
sessionOne per HTTP session (web only)Shopping cart, user preferences
applicationOne 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

StrategyExamplePros / 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

AnnotationApplies toValidates
@NotNullAnyMust not be null
@NotBlankStringNot null and has non-whitespace chars
@NotEmptyString, CollectionNot null and not empty
@Size(min,max)String, CollectionLength/size within range
@Min / @MaxNumberNumeric range
@Positive / @PositiveOrZeroNumberSign constraint
@EmailStringEmail format
@Pattern(regexp)StringRegex match
@Past / @FutureDate/timeTemporal constraint
@ValidObjectCascade 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

AnnotationLoadsUse 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

FactorServlet (MVC)Reactive (WebFlux)
Programming modelImperative, familiarFunctional/reactive, steeper curve
ConcurrencyThread-per-requestSmall event-loop thread pool
I/O bound workloadsNeeds large thread poolHighly efficient with few threads
CPU bound workloadsFineNo advantage (may be slower)
Spring Data JPAFull supportRequires R2DBC for non-blocking DB
Streaming responsesManualFirst-class (SSE, WebSocket)
Ecosystem maturityVery mature, huge ecosystemMature 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

AnnotationPackagePurpose
@SpringBootApplicationspring.bootCombines @Configuration + @EnableAutoConfiguration + @ComponentScan
@Componentspring.stereotypeGeneric Spring-managed bean
@Servicespring.stereotypeBusiness logic bean (semantic marker)
@Repositoryspring.stereotypeData access bean + exception translation
@Configurationspring.contextClass with @Bean factory methods
@Beanspring.contextFactory method producing a Spring bean
@Autowiredspring.beansDependency injection (optional with single constructor)
@Qualifierspring.beansDisambiguate multiple beans of same type
@Primaryspring.contextMark preferred bean when multiple candidates
@Scopespring.contextBean scope (singleton/prototype/request/session)
@Lazyspring.contextInitialize bean only when first needed
@PostConstructjakarta.annotationCalled after all deps injected
@PreDestroyjakarta.annotationCalled before bean destroyed
@Profilespring.contextActivate bean/config only for specified profile(s)
@Conditionalspring.contextRegister bean based on custom condition
@ConditionalOnPropertyspring.bootRegister bean based on property value
@ConditionalOnMissingBeanspring.bootRegister bean only if no bean of that type exists
@Valuespring.beansInject property or SpEL expression
@ConfigurationPropertiesspring.bootBind property prefix to typed object
@EnableConfigurationPropertiesspring.bootEnable @ConfigurationProperties class(es)

Web MVC

AnnotationPurpose
@RestController@Controller + @ResponseBody for REST APIs
@RequestMappingMap URL patterns to class or method
@GetMapping / @PostMapping / @PutMapping / @PatchMapping / @DeleteMappingHTTP method-specific shortcuts
@PathVariableBind URI template variable
@RequestParamBind query/form parameter
@RequestBodyDeserialize request body (JSON → object)
@ResponseBodySerialize return value to response body
@ResponseStatusSet default HTTP status code
@RequestHeaderBind HTTP header value
@CookieValueBind cookie value
@CrossOriginConfigure CORS for class or method
@ExceptionHandlerHandle specific exception types in controller
@ControllerAdvice / @RestControllerAdviceGlobal cross-cutting concern for controllers
@Valid / @ValidatedTrigger Bean Validation on argument

Spring Data JPA

AnnotationPurpose
@EntityMark class as JPA entity
@TableCustomize table name, indexes, unique constraints
@IdPrimary key field
@GeneratedValueID generation strategy (IDENTITY, SEQUENCE, UUID)
@ColumnCustomize column name, nullable, length, precision
@ManyToOne / @OneToMany / @OneToOne / @ManyToManyAssociation mappings
@JoinColumnCustomize FK column
@Enumerated(EnumType.STRING)Store enum as its string name
@TransientExclude field from persistence
@MappedSuperclassBase class for shared fields (not an entity itself)
@TransactionalDeclarative transaction management
@QueryCustom JPQL or native SQL query
@ModifyingMarks a @Query as DML (UPDATE/DELETE)
@CreatedDate / @LastModifiedDateAuto-populate audit timestamps
@CreatedBy / @LastModifiedByAuto-populate audit user fields

Security

AnnotationPurpose
@EnableWebSecurityActivate Spring Security web support
@EnableMethodSecurityEnable 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
@WithMockUserTest: simulate authenticated user in @WebMvcTest

Testing

AnnotationPurpose
@SpringBootTestFull application context for integration tests
@WebMvcTest(Class)Web layer slice test
@DataJpaTestJPA slice test with embedded DB
@MockBeanAdd Mockito mock to Spring context
@SpyBeanAdd Mockito spy wrapping real bean
@TestConfigurationAdditional config only for tests
@ActiveProfilesActivate profiles in test
@DynamicPropertySourceOverride properties in test (e.g., Testcontainers URLs)
@TestcontainersManage Testcontainers container lifecycle
@ContainerMark 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"}