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-appMinimal 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: falseapplication.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=falseProfiles
# 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)
- Command line arguments (
--server.port=9090) - Java system properties (
-Dserver.port=9090) - OS environment variables (
SERVER_PORT=9090) application-{profile}.yml(outside jar)application-{profile}.yml(inside jar)application.yml(outside jar)application.yml(inside jar)@PropertySourceannotations- 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 methodsBean 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 ServletContextInjection 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.json7. 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 runtimeCustom 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 → TRACEapplication.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: 30MDC (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 neededBuilding 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=prodKubernetes 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: ClusterIPGraceful 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 terminates11. Common Annotations Reference
| Annotation | Description |
|---|---|
| @SpringBootApplication | Entry point — combines @Configuration + @EnableAutoConfiguration + @ComponentScan |
| @RestController | Marks class as REST controller (@Controller + @ResponseBody) |
| @RequestMapping | Maps HTTP requests to handler methods; base path for controller |
| @GetMapping / @PostMapping | Shortcut for @RequestMapping(method = GET/POST) |
| @PutMapping / @DeleteMapping | Shortcut for @RequestMapping(method = PUT/DELETE) |
| @PathVariable | Binds URI template variable to method parameter |
| @RequestParam | Binds query parameter to method parameter |
| @RequestBody | Deserializes HTTP body to Java object (JSON → POJO) |
| @ResponseStatus | Sets HTTP status code for the response |
| @Valid | Triggers Bean Validation on the annotated parameter |
| @Component | Generic Spring-managed bean (auto-detected via component scanning) |
| @Service | Specialization of @Component for service layer |
| @Repository | Specialization of @Component for data access (adds exception translation) |
| @Configuration | Declares a class as a source of @Bean definitions |
| @Bean | Produces a Spring-managed bean from a method in @Configuration |
| @Autowired | Injects dependency (prefer constructor injection instead) |
| @Qualifier | Disambiguates when multiple beans of the same type exist |
| @Primary | Marks a bean as the default when multiple candidates exist |
| @Value | Injects a value from properties or environment variables |
| @ConfigurationProperties | Type-safe binding of configuration properties to a POJO |
| @Profile | Activates a bean only for specific profiles |
| @Transactional | Wraps method in a database transaction (default: REQUIRED) |
| @Entity | JPA: marks class as a persistent entity mapped to a DB table |
| @Table | JPA: specifies the database table name for the entity |
| @Id / @GeneratedValue | JPA: marks primary key and its generation strategy |
| @Column | JPA: customizes column mapping (name, nullable, unique, length) |
| @OneToMany / @ManyToOne | JPA: defines one-to-many / many-to-one relationships |
| @ManyToMany | JPA: defines many-to-many relationship with join table |
| @Query | JPA: defines custom JPQL or native SQL query on a repository method |
| @PreAuthorize | Spring Security: method-level authorization with SpEL expression |
| @EnableWebSecurity | Activates Spring Security web configuration |
| @EnableMethodSecurity | Enables method-level security annotations |
| @SpringBootTest | Full integration test with application context |
| @WebMvcTest | Slice test for web layer only (controllers) |
| @DataJpaTest | Slice test for JPA repositories with embedded DB |
| @MockBean | Adds a Mockito mock to the Spring application context |
| @ActiveProfiles | Activates specific profiles for test classes |
| @Scheduled | Executes a method on a cron/fixed schedule |
| @Async | Executes a method asynchronously in a separate thread |
| @Cacheable | Caches method return value; skips execution on cache hit |
| @ConditionalOnProperty | Conditionally 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=validatein prod — manage schema with Flyway/Liquibase - Use
@Transactionalon service layer — not on controllers or repositories - Write tests at every layer — @WebMvcTest, @DataJpaTest, @SpringBootTest
- Use
ResponseEntityfor explicit HTTP status control - Enable actuator health checks — liveness and readiness probes for K8s
- Use
FetchType.LAZYby default for JPA relationships - Use pagination for list endpoints — never return unbounded results
❌ AVOID
- Field injection (
@Autowiredon fields) — hides dependencies, hard to test - Exposing JPA entities in REST responses — leaks internal schema, circular references
ddl-auto=updatein production — unpredictable schema changes, data loss risk- Catching generic
Exceptioneverywhere — use specific exception types - Hardcoding config values — use externalized configuration
- God services — keep services focused and small (Single Responsibility)
@Transactionalon 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
@Validon 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