← Back to Presentations

Spring Boot Cheatsheet

Comprehensive Quick Reference for Spring Boot 3.x — Configuration, REST, JPA, Security, Testing & Deployment

1. Project Setup

Spring Initializr (CLI)

# Generate via curl
curl https://start.spring.io/starter.tgz \
  -d type=maven-project \
  -d language=java \
  -d bootVersion=3.3.0 \
  -d baseDir=my-app \
  -d groupId=com.example \
  -d artifactId=my-app \
  -d dependencies=web,data-jpa,postgresql,actuator,validation \
  | tar -xzvf -

# Or use Spring Boot CLI
spring init --dependencies=web,data-jpa,postgresql my-app

Minimal Maven pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.0</version>
  </parent>
  <groupId>com.example</groupId>
  <artifactId>my-app</artifactId>
  <version>0.0.1-SNAPSHOT</version>

  <properties>
    <java.version>21</java.version>
  </properties>

  <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>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

Minimal Gradle build.gradle.kts

plugins {
    java
    id("org.springframework.boot") version "3.3.0"
    id("io.spring.dependency-management") version "1.1.5"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories { mavenCentral() }

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<Test> { useJUnitPlatform() }

Project Structure

my-app/
├── src/main/java/com/example/myapp/
│   ├── MyAppApplication.java        # Main class
│   ├── controller/                   # REST controllers
│   ├── service/                      # Business logic
│   ├── repository/                   # Data access
│   ├── model/                        # JPA entities
│   ├── dto/                          # Data Transfer Objects
│   └── config/                       # Configuration classes
├── src/main/resources/
│   ├── application.yml               # Main config
│   ├── application-dev.yml           # Dev profile
│   ├── application-prod.yml          # Prod profile
│   └── db/migration/                 # Flyway migrations
├── src/test/java/com/example/myapp/
└── pom.xml (or build.gradle.kts)

Main Application Class

package com.example.myapp;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication  // = @Configuration + @EnableAutoConfiguration + @ComponentScan
public class MyAppApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyAppApplication.class, args);
    }
}

2. Application Configuration

application.yml

server:
  port: 8080
  servlet:
    context-path: /api

spring:
  application:
    name: my-app
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: ${DB_USER:postgres}
    password: ${DB_PASS:secret}
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false
    open-in-view: false

application.properties (equivalent)

server.port=8080
server.servlet.context-path=/api

spring.application.name=my-app
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=${DB_USER:postgres}
spring.datasource.password=${DB_PASS:secret}
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
spring.jpa.open-in-view=false

Profiles

# application-dev.yml
spring:
  datasource:
    url: jdbc:h2:mem:devdb
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true

---
# Activate profile
# CLI:   java -jar app.jar --spring.profiles.active=dev
# Env:   SPRING_PROFILES_ACTIVE=dev
# YAML:  spring.profiles.active: dev

@Value & @ConfigurationProperties

// Simple injection with @Value
@Value("${app.feature.enabled:false}")
private boolean featureEnabled;

@Value("${app.name}")
private String appName;

// Type-safe config with @ConfigurationProperties
@Configuration
@ConfigurationProperties(prefix = "app.mail")
public class MailProperties {
    private String host;
    private int port = 587;
    private String username;
    private String password;
    // getters & setters (or use record / Lombok)
}

// Usage in application.yml:
// app:
//   mail:
//     host: smtp.example.com
//     port: 465
//     username: user
//     password: secret

📋 Config Precedence (highest → lowest)

  1. Command line arguments (--server.port=9090)
  2. Java system properties (-Dserver.port=9090)
  3. OS environment variables (SERVER_PORT=9090)
  4. application-{profile}.yml (outside jar)
  5. application-{profile}.yml (inside jar)
  6. application.yml (outside jar)
  7. application.yml (inside jar)
  8. @PropertySource annotations
  9. Default properties

3. Web & REST

REST Controller

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {  // constructor injection
        this.userService = userService;
    }

    @GetMapping
    public List<UserDto> findAll(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        return userService.findAll(page, size);
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserDto> findById(@PathVariable Long id) {
        return userService.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserDto create(@Valid @RequestBody CreateUserRequest request) {
        return userService.create(request);
    }

    @PutMapping("/{id}")
    public UserDto update(@PathVariable Long id,
                          @Valid @RequestBody UpdateUserRequest request) {
        return userService.update(id, request);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        userService.delete(id);
    }
}

Global Exception Handling

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
        return new ErrorResponse("NOT_FOUND", ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = ex.getBindingResult()
            .getFieldErrors().stream()
            .collect(Collectors.toMap(
                FieldError::getField,
                f -> f.getDefaultMessage() != null ? f.getDefaultMessage() : "invalid"
            ));
        return new ErrorResponse("VALIDATION_FAILED", "Validation failed", errors);
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse handleGeneral(Exception ex) {
        return new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred");
    }
}

record ErrorResponse(String code, String message, Object details) {
    ErrorResponse(String code, String message) { this(code, message, null); }
}

Bean Validation

public record CreateUserRequest(
    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100)
    String name,

    @NotBlank @Email
    String email,

    @Min(18) @Max(150)
    int age,

    @NotNull
    @Pattern(regexp = "^\\+[1-9]\\d{1,14}$", message = "Invalid phone")
    String phone
) {}

// Common validation annotations:
// @NotNull, @NotBlank, @NotEmpty, @Size, @Min, @Max,
// @Email, @Pattern, @Positive, @PositiveOrZero,
// @Past, @Future, @Valid (cascading)

4. Dependency Injection

Stereotype Annotations

@Component    // Generic Spring bean
@Service      // Business logic layer
@Repository   // Data access layer (adds
              // exception translation)
@Controller   // MVC controller
@RestController // = @Controller + @ResponseBody
@Configuration // Declares @Bean methods

Bean Scopes

@Scope("singleton")   // Default — one per context
@Scope("prototype")   // New instance each time
@Scope("request")     // One per HTTP request
@Scope("session")     // One per HTTP session
@Scope("application") // One per ServletContext

Injection Patterns

// ✅ PREFERRED: Constructor injection (immutable, testable)
@Service
public class OrderService {
    private final UserRepository userRepo;
    private final PaymentGateway paymentGateway;

    public OrderService(UserRepository userRepo,
                        PaymentGateway paymentGateway) {
        this.userRepo = userRepo;
        this.paymentGateway = paymentGateway;
    }
}

// ❌ AVOID: Field injection (hard to test, hides dependencies)
@Service
public class OrderService {
    @Autowired private UserRepository userRepo;
    @Autowired private PaymentGateway paymentGateway;
}

// @Qualifier — disambiguate multiple implementations
@Service
public class NotificationService {
    public NotificationService(
            @Qualifier("emailSender") MessageSender sender) {
        this.sender = sender;
    }
}

// @Primary — mark default implementation
@Primary
@Component("emailSender")
public class EmailSender implements MessageSender { ... }

@Component("smsSender")
public class SmsSender implements MessageSender { ... }

@Bean in @Configuration

@Configuration
public class AppConfig {

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder) {
        return builder
                .setConnectTimeout(Duration.ofSeconds(5))
                .setReadTimeout(Duration.ofSeconds(10))
                .build();
    }

    @Bean
    public ObjectMapper objectMapper() {
        return JsonMapper.builder()
                .addModule(new JavaTimeModule())
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                .build();
    }
}

5. Data Access (JPA/Hibernate)

Entity Definition

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String name;

    @Column(unique = true, nullable = false)
    private String email;

    @Enumerated(EnumType.STRING)
    private Role role = Role.USER;

    @CreationTimestamp
    private LocalDateTime createdAt;

    @UpdateTimestamp
    private LocalDateTime updatedAt;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL,
               orphanRemoval = true, fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();

    // constructors, getters, setters
}

Relationships

// One-to-Many (User has many Orders)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Order> orders;

// Many-to-One (Order belongs to User)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

// Many-to-Many
@ManyToMany
@JoinTable(name = "user_roles",
    joinColumns = @JoinColumn(name = "user_id"),
    inverseJoinColumns = @JoinColumn(name = "role_id"))
private Set<Role> roles = new HashSet<>();

// One-to-One
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private UserProfile profile;

Repository

public interface UserRepository extends JpaRepository<User, Long> {

    // Derived query methods
    Optional<User> findByEmail(String email);
    List<User> findByRoleAndActiveTrue(Role role);
    boolean existsByEmail(String email);
    long countByRole(Role role);

    // Custom JPQL
    @Query("SELECT u FROM User u WHERE u.email LIKE %:domain")
    List<User> findByEmailDomain(@Param("domain") String domain);

    // Native SQL
    @Query(value = "SELECT * FROM users WHERE created_at > :since",
           nativeQuery = true)
    List<User> findRecentUsers(@Param("since") LocalDateTime since);

    // Pagination
    Page<User> findByRole(Role role, Pageable pageable);

    // Modifying queries
    @Modifying
    @Query("UPDATE User u SET u.active = false WHERE u.lastLogin < :date")
    int deactivateInactiveUsers(@Param("date") LocalDateTime date);
}

Database Config & Migrations

# application.yml — DB config
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: ${DB_USER}
    password: ${DB_PASS}
    hikari:
      maximum-pool-size: 10
      minimum-idle: 5
  jpa:
    hibernate:
      ddl-auto: validate   # none | validate | update | create-drop
    properties:
      hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect
  flyway:
    enabled: true
    locations: classpath:db/migration

---
# Flyway migration: src/main/resources/db/migration/V1__create_users.sql
CREATE TABLE users (
    id         BIGSERIAL PRIMARY KEY,
    name       VARCHAR(100) NOT NULL,
    email      VARCHAR(255) NOT NULL UNIQUE,
    role       VARCHAR(20) DEFAULT 'USER',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

6. Security (Spring Security)

SecurityFilterChain Configuration

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // enables @PreAuthorize
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())  // disable for APIs
            .sessionManagement(sm ->
                sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**", "/actuator/health").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.POST, "/api/users").hasAuthority("USER_CREATE")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 ->
                oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter())))
            .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public JwtAuthenticationConverter jwtConverter() {
        var converter = new JwtGrantedAuthoritiesConverter();
        converter.setAuthorityPrefix("ROLE_");
        converter.setAuthoritiesClaimName("roles");
        var jwtConverter = new JwtAuthenticationConverter();
        jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
        return jwtConverter;
    }
}

Method-Level Security

@Service
public class UserService {

    @PreAuthorize("hasRole('ADMIN')")
    public void deleteUser(Long id) { ... }

    @PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
    public UserDto getUser(Long userId) { ... }

    @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
    public List<UserDto> listAll() { ... }
}

CORS Configuration

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    var config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://myapp.com"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);

    var source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

JWT application.yml

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com/realms/myapp
          # OR use JWK set URI directly:
          # jwk-set-uri: https://auth.example.com/.well-known/jwks.json

7. Testing

Integration Test (@SpringBootTest)

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class UserControllerIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void shouldCreateUser() {
        var request = new CreateUserRequest("John", "[email protected]", 30, "+1234567890");
        var response = restTemplate.postForEntity("/api/v1/users", request, UserDto.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody()).isNotNull();
        assertThat(response.getBody().name()).isEqualTo("John");
    }
}

Web Layer Test (@WebMvcTest)

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    void shouldReturnUser() throws Exception {
        var user = new UserDto(1L, "John", "[email protected]");
        when(userService.findById(1L)).thenReturn(Optional.of(user));

        mockMvc.perform(get("/api/v1/users/1")
                .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("John"))
            .andExpect(jsonPath("$.email").value("[email protected]"));
    }

    @Test
    void shouldReturn404WhenNotFound() throws Exception {
        when(userService.findById(99L)).thenReturn(Optional.empty());

        mockMvc.perform(get("/api/v1/users/99"))
            .andExpect(status().isNotFound());
    }
}

Repository Test (@DataJpaTest)

@DataJpaTest
@ActiveProfiles("test")
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldFindByEmail() {
        var user = new User("John", "[email protected]");
        userRepository.save(user);

        var found = userRepository.findByEmail("[email protected]");
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("John");
    }
}

Testcontainers

@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class UserServiceTestcontainersTest {

    @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
    private UserService userService;

    @Test
    void shouldPersistUser() {
        var user = userService.create(new CreateUserRequest("Jane", "[email protected]", 25, "+1234567890"));
        assertThat(user.id()).isNotNull();
    }
}

8. Actuator & Monitoring

Actuator Configuration

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus,env,loggers
      base-path: /actuator
  endpoint:
    health:
      show-details: when_authorized
      probes:
        enabled: true    # /actuator/health/liveness + /readiness
  info:
    env:
      enabled: true
  metrics:
    tags:
      application: ${spring.application.name}

# Key endpoints:
# GET /actuator/health           → health status
# GET /actuator/info             → app info
# GET /actuator/metrics          → available metrics
# GET /actuator/metrics/{name}   → specific metric
# GET /actuator/prometheus       → Prometheus scrape endpoint
# GET /actuator/env              → environment properties
# POST /actuator/loggers/{name}  → change log level at runtime

Custom Health Indicator

@Component
public class DatabaseHealthIndicator implements HealthIndicator {

    private final DataSource dataSource;

    public DatabaseHealthIndicator(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public Health health() {
        try (var conn = dataSource.getConnection()) {
            if (conn.isValid(2)) {
                return Health.up()
                    .withDetail("database", "PostgreSQL")
                    .withDetail("pool.active", getActiveConnections())
                    .build();
            }
        } catch (SQLException e) {
            return Health.down(e).build();
        }
        return Health.down().build();
    }
}

Custom Metrics with Micrometer

@Service
public class OrderService {
    private final Counter orderCounter;
    private final Timer orderTimer;

    public OrderService(MeterRegistry registry) {
        this.orderCounter = Counter.builder("orders.created")
            .description("Number of orders created")
            .tag("type", "web")
            .register(registry);
        this.orderTimer = Timer.builder("orders.processing.time")
            .description("Order processing time")
            .register(registry);
    }

    public Order createOrder(OrderRequest request) {
        return orderTimer.record(() -> {
            Order order = processOrder(request);
            orderCounter.increment();
            return order;
        });
    }
}

// Prometheus scrape config (prometheus.yml):
// scrape_configs:
//   - job_name: 'spring-boot'
//     metrics_path: '/actuator/prometheus'
//     static_configs:
//       - targets: ['localhost:8080']

9. Logging

SLF4J Usage

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Service
public class UserService {
    private static final Logger log = LoggerFactory.getLogger(UserService.class);
    // Or with Lombok: @Slf4j on the class

    public UserDto findById(Long id) {
        log.debug("Looking up user with id={}", id);
        var user = userRepository.findById(id)
            .orElseThrow(() -> {
                log.warn("User not found: id={}", id);
                return new ResourceNotFoundException("User", id);
            });
        log.info("Found user: id={}, email={}", user.getId(), user.getEmail());
        return toDto(user);
    }
}

// Log levels (least to most verbose):
// ERROR → WARN → INFO → DEBUG → TRACE

application.yml Log Config

logging:
  level:
    root: INFO
    com.example.myapp: DEBUG
    com.example.myapp.repository: TRACE
    org.springframework.web: WARN
    org.hibernate.SQL: DEBUG           # show SQL
    org.hibernate.orm.jdbc.bind: TRACE # show bind params
  pattern:
    console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
  file:
    name: logs/application.log
  logback:
    rollingpolicy:
      max-file-size: 10MB
      max-history: 30

MDC (Mapped Diagnostic Context)

// Filter for request correlation
@Component
public class CorrelationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String correlationId = request.getHeader("X-Correlation-ID");
        if (correlationId == null) {
            correlationId = UUID.randomUUID().toString();
        }
        MDC.put("correlationId", correlationId);
        response.setHeader("X-Correlation-ID", correlationId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.clear();
        }
    }
}

Structured JSON Logging (logback-spring.xml)

<!-- src/main/resources/logback-spring.xml -->
<configuration>
  <springProfile name="prod">
    <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
      <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <includeMdcKeyName>correlationId</includeMdcKeyName>
      </encoder>
    </appender>
    <root level="INFO">
      <appender-ref ref="JSON" />
    </root>
  </springProfile>

  <springProfile name="dev">
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
      <encoder>
        <pattern>%d{HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) - %msg%n</pattern>
      </encoder>
    </appender>
    <root level="DEBUG">
      <appender-ref ref="CONSOLE" />
    </root>
  </springProfile>
</configuration>

10. Deployment & DevOps

Multi-Stage Dockerfile

# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN apk add --no-cache maven && \
    mvn clean package -DskipTests -q

# Stage 2: Run
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring
COPY --from=build /app/target/*.jar app.jar
USER spring:spring
EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget -q --spider http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["java", \
  "-XX:+UseContainerSupport", \
  "-XX:MaxRAMPercentage=75.0", \
  "-jar", "app.jar"]

Cloud-Native Buildpacks (no Dockerfile needed)

# Maven
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myapp:latest

# Gradle
./gradlew bootBuildImage --imageName=myapp:latest

# Uses Paketo buildpacks — optimized layers, no Dockerfile needed

Building Fat JAR

# Maven
./mvnw clean package -DskipTests
java -jar target/my-app-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

# Gradle
./gradlew bootJar
java -jar build/libs/my-app-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
      - name: my-app
        image: registry.example.com/my-app:latest
        ports:
        - containerPort: 8080
        env:
        - name: SPRING_PROFILES_ACTIVE
          value: "prod"
        - name: DB_PASS
          valueFrom:
            secretKeyRef:
              name: db-secret
              key: password
        resources:
          requests:
            memory: "512Mi"
            cpu: "250m"
          limits:
            memory: "1Gi"
            cpu: "1000m"
        startupProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 5
          failureThreshold: 30
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          periodSeconds: 15
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: my-app
spec:
  selector:
    app: my-app
  ports:
  - port: 80
    targetPort: 8080
  type: ClusterIP

Graceful Shutdown

# application.yml
server:
  shutdown: graceful

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

# Kubernetes: set terminationGracePeriodSeconds >= timeout
# This ensures in-flight requests complete before pod terminates

11. Common Annotations Reference

AnnotationDescription
@SpringBootApplicationEntry point — combines @Configuration + @EnableAutoConfiguration + @ComponentScan
@RestControllerMarks class as REST controller (@Controller + @ResponseBody)
@RequestMappingMaps HTTP requests to handler methods; base path for controller
@GetMapping / @PostMappingShortcut for @RequestMapping(method = GET/POST)
@PutMapping / @DeleteMappingShortcut for @RequestMapping(method = PUT/DELETE)
@PathVariableBinds URI template variable to method parameter
@RequestParamBinds query parameter to method parameter
@RequestBodyDeserializes HTTP body to Java object (JSON → POJO)
@ResponseStatusSets HTTP status code for the response
@ValidTriggers Bean Validation on the annotated parameter
@ComponentGeneric Spring-managed bean (auto-detected via component scanning)
@ServiceSpecialization of @Component for service layer
@RepositorySpecialization of @Component for data access (adds exception translation)
@ConfigurationDeclares a class as a source of @Bean definitions
@BeanProduces a Spring-managed bean from a method in @Configuration
@AutowiredInjects dependency (prefer constructor injection instead)
@QualifierDisambiguates when multiple beans of the same type exist
@PrimaryMarks a bean as the default when multiple candidates exist
@ValueInjects a value from properties or environment variables
@ConfigurationPropertiesType-safe binding of configuration properties to a POJO
@ProfileActivates a bean only for specific profiles
@TransactionalWraps method in a database transaction (default: REQUIRED)
@EntityJPA: marks class as a persistent entity mapped to a DB table
@TableJPA: specifies the database table name for the entity
@Id / @GeneratedValueJPA: marks primary key and its generation strategy
@ColumnJPA: customizes column mapping (name, nullable, unique, length)
@OneToMany / @ManyToOneJPA: defines one-to-many / many-to-one relationships
@ManyToManyJPA: defines many-to-many relationship with join table
@QueryJPA: defines custom JPQL or native SQL query on a repository method
@PreAuthorizeSpring Security: method-level authorization with SpEL expression
@EnableWebSecurityActivates Spring Security web configuration
@EnableMethodSecurityEnables method-level security annotations
@SpringBootTestFull integration test with application context
@WebMvcTestSlice test for web layer only (controllers)
@DataJpaTestSlice test for JPA repositories with embedded DB
@MockBeanAdds a Mockito mock to the Spring application context
@ActiveProfilesActivates specific profiles for test classes
@ScheduledExecutes a method on a cron/fixed schedule
@AsyncExecutes a method asynchronously in a separate thread
@CacheableCaches method return value; skips execution on cache hit
@ConditionalOnPropertyConditionally creates a bean based on a config property

12. Best Practices & Anti-Patterns

✅ DO

  • Use constructor injection — immutable, testable, explicit dependencies
  • Use DTOs for API request/response — never expose JPA entities directly
  • Use profiles (dev/test/prod) — separate config per environment
  • Externalize secrets via env vars or vaults — never hardcode passwords
  • Set spring.jpa.open-in-view=false — avoid lazy-loading surprises in controllers
  • Use ddl-auto=validate in prod — manage schema with Flyway/Liquibase
  • Use @Transactional on service layer — not on controllers or repositories
  • Write tests at every layer — @WebMvcTest, @DataJpaTest, @SpringBootTest
  • Use ResponseEntity for explicit HTTP status control
  • Enable actuator health checks — liveness and readiness probes for K8s
  • Use FetchType.LAZY by default for JPA relationships
  • Use pagination for list endpoints — never return unbounded results

❌ AVOID

  • Field injection (@Autowired on fields) — hides dependencies, hard to test
  • Exposing JPA entities in REST responses — leaks internal schema, circular references
  • ddl-auto=update in production — unpredictable schema changes, data loss risk
  • Catching generic Exception everywhere — use specific exception types
  • Hardcoding config values — use externalized configuration
  • God services — keep services focused and small (Single Responsibility)
  • @Transactional on private methods — Spring proxies only intercept public methods
  • N+1 queries — use JOIN FETCH, @EntityGraph, or @BatchSize
  • Ignoring connection pool tuning — configure HikariCP for your load
  • Skipping validation — always use @Valid on incoming requests

💡 @Transactional Cheat Sheet

// Default: propagation=REQUIRED, rollbackFor=RuntimeException
@Transactional
public void createOrder(OrderRequest req) { ... }

// Read-only (optimization hint)
@Transactional(readOnly = true)
public List<OrderDto> findAll() { ... }

// Rollback on checked exceptions too
@Transactional(rollbackFor = Exception.class)
public void processPayment() throws PaymentException { ... }

// New transaction (independent of caller)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendNotification() { ... }

// ⚠️ Self-invocation bypasses proxy!
// This WON'T work:
public void outer() {
    inner();  // ❌ no transaction — direct call, not through proxy
}

@Transactional
public void inner() { ... }

// Fix: inject self, or extract to separate service