Spring Boot Production Engineering
Complete production engineering methodology for Spring Boot & Java/Kotlin applications — architecture, security, observability, testing, deployment, and performance optimization.
Quick Health Check
Score your Spring Boot application (1 = needs work, 2 = acceptable):
| Signal | Check | /2 |
|---|---|---|
| 🏗️ Architecture | Clean layered architecture with dependency injection? | |
| 🔒 Security | Spring Security configured with proper auth + CORS + CSRF? | |
| 📊 Observability | Structured logging + metrics + health endpoints? | |
| 🧪 Testing | Unit + integration + slice tests with >70% coverage? | |
| ⚡ Performance | Connection pooling + caching + async where appropriate? | |
| 🚀 Deployment | Containerized with CI/CD + zero-downtime deploys? | |
| 📝 API Design | OpenAPI docs + versioning + consistent error responses? | |
| 🛡️ Resilience | Circuit breakers + retries + graceful degradation? |
Score: /16 → ≤8 Critical · 9-12 Improving · 13-14 Good · 15-16 Production-ready
Phase 1: Project Architecture
Recommended Project Structure
src/main/java/com/example/app/
├── Application.java # @SpringBootApplication entry
├── config/ # Configuration classes
│ ├── SecurityConfig.java
│ ├── WebConfig.java
│ ├── CacheConfig.java
│ └── AsyncConfig.java
├── domain/ # Domain models & business logic
│ ├── model/ # JPA entities / domain objects
│ ├── repository/ # Spring Data repositories
│ ├── service/ # Business logic services
│ └── event/ # Domain events
├── api/ # REST controllers
│ ├── controller/ # @RestController classes
│ ├── dto/ # Request/Response DTOs
│ ├── mapper/ # Entity ↔ DTO mappers
│ └── exception/ # API exception handlers
├── infrastructure/ # External integrations
│ ├── client/ # REST/gRPC clients
│ ├── messaging/ # Kafka/RabbitMQ producers/consumers
│ └── storage/ # S3/file storage
└── common/ # Shared utilities
├── exception/ # Base exceptions
├── validation/ # Custom validators
└── util/ # Helpers
7 Architecture Rules
- Controllers are thin — validate input, call service, return DTO. No business logic.
- Services own business logic — transaction boundaries live here.
- Repositories are interfaces — Spring Data generates implementations.
- DTOs at boundaries — never expose JPA entities in API responses.
- Constructor injection only — no
@Autowiredon fields (testability). - Package by feature for large apps — when >20 services, switch from layer-based to feature-based.
- No circular dependencies — if A depends on B depends on A, extract shared logic to C.
Spring Boot Starter Selection
# build.gradle.kts (recommended over Maven for Kotlin DSL + type safety)
dependencies:
# Core
- spring-boot-starter-web # REST APIs (embedded Tomcat)
- spring-boot-starter-webflux # Reactive APIs (Netty) — choose ONE
- spring-boot-starter-validation # Bean Validation (Jakarta)
# Data
- spring-boot-starter-data-jpa # JPA + Hibernate
- spring-boot-starter-data-redis # Redis caching
# Security
- spring-boot-starter-security # Spring Security
- spring-boot-starter-oauth2-resource-server # JWT validation
# Observability
- spring-boot-starter-actuator # Health, metrics, info
- micrometer-registry-prometheus # Prometheus metrics export
# Resilience
- resilience4j-spring-boot3 # Circuit breaker, retry, rate limit
# Testing
- spring-boot-starter-test # JUnit 5 + Mockito + AssertJ
- spring-boot-testcontainers # Real DB/Redis in tests
Framework Decision: Spring Boot vs Alternatives
| Factor | Spring Boot | Quarkus | Micronaut | Ktor (Kotlin) |
|---|---|---|---|---|
| Startup time | 2-5s | 0.5-1s | 1-2s | 1-2s |
| Memory | 200-400MB | 50-150MB | 100-200MB | 80-150MB |
| Ecosystem | ★★★★★ | ★★★☆☆ | ★★★☆☆ | ★★☆☆☆ |
| Enterprise adoption | Dominant | Growing | Niche | Niche |
| Native compilation | GraalVM (complex) | Native (easy) | Native (easy) | GraalVM |
| Team hiring | Easy | Hard | Hard | Hard |
Decision rule: Spring Boot unless startup time <1s is critical (serverless/CLI) → Quarkus.
Phase 2: Configuration & Profiles
application.yml Production Template
spring:
application:
name: ${APP_NAME:my-service}
profiles:
active: ${SPRING_PROFILES_ACTIVE:local}
# Database
datasource:
url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/mydb}
username: ${DATABASE_USERNAME:postgres}
password: ${DATABASE_PASSWORD:postgres}
hikari:
maximum-pool-size: ${DB_POOL_SIZE:10}
minimum-idle: ${DB_POOL_MIN:5}
connection-timeout: 3000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
jpa:
open-in-view: false # CRITICAL — disable OSIV anti-pattern
hibernate:
ddl-auto: validate # Production: NEVER use update/create
properties:
hibernate:
default_batch_fetch_size: 25
order_inserts: true
order_updates: true
jdbc:
batch_size: 50
batch_versioned_data: true
# Jackson
jackson:
default-property-inclusion: non_null
serialization:
write-dates-as-timestamps: false
deserialization:
fail-on-unknown-properties: false
# Cache
cache:
type: redis
redis:
time-to-live: 3600000 # 1 hour default
server:
port: ${SERVER_PORT:8080}
shutdown: graceful # Wait for active requests
tomcat:
max-threads: ${TOMCAT_MAX_THREADS:200}
accept-count: 100
connection-timeout: 5000
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
endpoint:
health:
show-details: when-authorized
probes:
enabled: true # Kubernetes liveness/readiness
metrics:
tags:
application: ${spring.application.name}
# Graceful shutdown
spring.lifecycle.timeout-per-shutdown-phase: 30s
Profile Strategy
| Profile | Purpose | Config |
|---|---|---|
local | Development | H2/local Postgres, debug logging |
test | Testing | Testcontainers, no external deps |
staging | Pre-production | Real deps, reduced resources |
production | Live | Full resources, minimal logging |
Configuration Rules
- Never hardcode secrets — always use environment variables or vault
- Disable
open-in-view— prevents lazy loading in controller layer (performance killer) - Set
ddl-auto: validatein production — use Flyway/Liquibase for migrations - Configure HikariCP explicitly — defaults are often wrong for production
- Enable graceful shutdown —
server.shutdown: graceful+ timeout
Phase 3: JPA & Database Patterns
Entity Design
@MappedSuperclass
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreationTimestamp
@Column(updatable = false)
private Instant createdAt;
@UpdateTimestamp
private Instant updatedAt;
@Version // Optimistic locking
private Long version;
}
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_users_email", columnList = "email", unique = true),
@Index(name = "idx_users_status", columnList = "status")
})
public class User extends BaseEntity {
@Column(nullable = false, length = 255)
private String email;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private UserStatus status;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY) // ALWAYS lazy
private List<Order> orders = new ArrayList<>();
}
N+1 Prevention
// ❌ N+1 problem — loads each user's orders individually
List<User> users = userRepository.findAll();
users.forEach(u -> u.getOrders().size()); // N additional queries
// ✅ JOIN FETCH — single query
@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.status = :status")
List<User> findByStatusWithOrders(@Param("status") UserStatus status);
// ✅ EntityGraph — declarative
@EntityGraph(attributePaths = {"orders", "orders.items"})
List<User> findByStatus(UserStatus status);
// ✅ Batch fetching (configured globally)
# application.yml: hibernate.default_batch_fetch_size: 25
Repository Patterns
public interface UserRepository extends JpaRepository<User, Long> {
// Derived queries — simple cases only
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
// Projections — return only needed fields
@Query("SELECT new com.example.dto.UserSummary(u.id, u.email, u.status) " +
"FROM User u WHERE u.status = :status")
List<UserSummary> findSummariesByStatus(@Param("status") UserStatus status);
// Pagination
Page<User> findByStatus(UserStatus status, Pageable pageable);
// Bulk operations — bypass Hibernate cache
@Modifying(clearAutomatically = true)
@Query("UPDATE User u SET u.status = :status WHERE u.lastLoginAt < :threshold")
int deactivateInactiveUsers(@Param("status") UserStatus status,
@Param("threshold") Instant threshold);
}
Migration with Flyway
-- V1__create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
version BIGINT NOT NULL DEFAULT 0,
CONSTRAINT uk_users_email UNIQUE (email)
);
CREATE INDEX idx_users_status ON users(status);
8 JPA Rules
- Always use
FetchType.LAZY— eager loading causes N+1 - Use
@Versionfor optimistic locking — prevents lost updates - Prefer projections over full entities —
SELECT new DTO(...)for read-only - Batch inserts/updates — configure
batch_size+order_inserts - Never use
ddl-auto: updatein production — Flyway/Liquibase only - Use
@NaturalIdfor business keys — email, ISBN, etc. - Avoid bidirectional mappings unless needed — more complexity, more bugs
- Test queries with real database — Testcontainers, not H2
Phase 4: REST API Design
Controller Pattern
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Validated
public class UserController {
private final UserService userService;
private final UserMapper userMapper;
@GetMapping
public Page<UserResponse> listUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) UserStatus status) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return userService.findUsers(status, pageable)
.map(userMapper::toResponse);
}
@GetMapping("/{id}")
public UserResponse getUser(@PathVariable Long id) {
return userMapper.toResponse(userService.findById(id));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
User user = userService.create(request);
return userMapper.toResponse(user);
}
@PutMapping("/{id}")
public UserResponse updateUser(@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
User user = userService.update(id, request);
return userMapper.toResponse(user);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
userService.delete(id);
}
}
DTO Validation
public record CreateUserRequest(
@NotBlank @Email @Size(max = 255)
String email,
@NotBlank @Size(min = 2, max = 100)
String name,
@NotNull
UserRole role
) {}
public record UserResponse(
Long id,
String email,
String name,
UserStatus status,
Instant createdAt
) {}
Global Error Handling
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(EntityNotFoundException 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,
fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "invalid",
(a, b) -> a
));
return new ErrorResponse("VALIDATION_ERROR", "Invalid request", errors);
}
@ExceptionHandler(DataIntegrityViolationException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ErrorResponse handleConflict(DataIntegrityViolationException ex) {
return new ErrorResponse("CONFLICT", "Resource already exists");
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleUnexpected(Exception ex) {
log.error("Unexpected error", ex);
return new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred");
}
}
public record ErrorResponse(
String code,
String message,
@JsonInclude(JsonInclude.Include.NON_NULL)
Map<String, String> details
) {
public ErrorResponse(String code, String message) {
this(code, message, null);
}
}
Phase 5: Security
Spring Security 6 Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // Disable for stateless APIs
.cors(cors -> cors.configurationSource(corsConfig()))
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter()))
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> {
res.setStatus(401);
res.getWriter().write("{\"code\":\"UNAUTHORIZED\",\"message\":\"Invalid or missing token\"}");
})
)
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'"))
.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)
)
.build();
}
private CorsConfigurationSource corsConfig() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
private JwtAuthenticationConverter jwtConverter() {
JwtGrantedAuthoritiesConverter authorities = new JwtGrantedAuthoritiesConverter();
authorities.setAuthorityPrefix("ROLE_");
authorities.setAuthoritiesClaimName("roles");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authorities);
return converter;
}
}
10-Point Security Checklist
| # | Check | Priority |
|---|---|---|
| 1 | CSRF disabled for stateless APIs, enabled for session-based | P0 |
| 2 | CORS configured with specific origins (no wildcards in prod) | P0 |
| 3 | JWT validation with proper issuer/audience checks | P0 |
| 4 | Input validation on all request DTOs (@Valid) | P0 |
| 5 | SQL injection prevention (parameterized queries only) | P0 |
| 6 | Secrets in environment variables or vault (never in code) | P0 |
| 7 | Security headers (CSP, X-Frame-Options, HSTS) | P1 |
| 8 | Rate limiting on auth endpoints | P1 |
| 9 | Dependency vulnerability scanning (OWASP, Snyk) | P1 |
| 10 | Method-level security (@PreAuthorize) for sensitive operations | P1 |
Phase 6: Service Layer & Business Logic
Service Pattern
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // Default read-only
@Slf4j
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final ApplicationEventPublisher eventPublisher;
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User not found: " + id));
}
public Page<User> findUsers(UserStatus status, Pageable pageable) {
if (status != null) {
return userRepository.findByStatus(status, pageable);
}
return userRepository.findAll(pageable);
}
@Transactional // Write transaction
public User create(CreateUserRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new ConflictException("Email already registered: " + request.email());
}
User user = User.builder()
.email(request.email())
.name(request.name())
.status(UserStatus.ACTIVE)
.build();
user = userRepository.save(user);
eventPublisher.publishEvent(new UserCreatedEvent(user.getId(), user.getEmail()));
log.info("User created: id={}, email={}", user.getId(), user.getEmail());
return user;
}
@Transactional
@CacheEvict(value = "users", key = "#id")
public User update(Long id, UpdateUserRequest request) {
User user = findById(id);
// Update fields...
return userRepository.save(user);
}
}
Domain Events
public record UserCreatedEvent(Long userId, String email) {}
@Component
@RequiredArgsConstructor
@Slf4j
public class UserEventListener {
private final EmailService emailService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void onUserCreated(UserCreatedEvent event) {
log.info("Sending welcome email to user: {}", event.userId());
emailService.sendWelcome(event.email());
}
}
Phase 7: Caching
Redis Cache Configuration
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()
))
.disableCachingNullValues();
Map<String, RedisCacheConfiguration> configs = Map.of(
"users", defaults.entryTtl(Duration.ofMinutes(30)),
"products", defaults.entryTtl(Duration.ofHours(2)),
"config", defaults.entryTtl(Duration.ofHours(24))
);
return RedisCacheManager.builder(factory)
.cacheDefaults(defaults)
.withInitialCacheConfigurations(configs)
.build();
}
}
Cache Usage
@Cacheable(value = "users", key = "#id")
public UserResponse getUserById(Long id) { ... }
@CachePut(value = "users", key = "#result.id")
public UserResponse updateUser(Long id, UpdateUserRequest req) { ... }
@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) { ... }
@CacheEvict(value = "users", allEntries = true)
@Scheduled(fixedRate = 3600000) // Hourly full invalidation
public void evictAllUsers() { ... }
Phase 8: Resilience
Resilience4j Configuration
resilience4j:
circuitbreaker:
instances:
payment-service:
sliding-window-size: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
slow-call-duration-threshold: 2s
slow-call-rate-threshold: 80
retry:
instances:
payment-service:
max-attempts: 3
wait-duration: 500ms
exponential-backoff-multiplier: 2
retry-exceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
ignore-exceptions:
- com.example.exception.BusinessException
ratelimiter:
instances:
api:
limit-for-period: 100
limit-refresh-period: 1s
timeout-duration: 0s
Usage
@CircuitBreaker(name = "payment-service", fallbackMethod = "paymentFallback")
@Retry(name = "payment-service")
public PaymentResponse processPayment(PaymentRequest request) {
return paymentClient.charge(request);
}
private PaymentResponse paymentFallback(PaymentRequest request, Throwable t) {
log.warn("Payment service unavailable, queuing for retry: {}", t.getMessage());
paymentQueue.enqueue(request);
return PaymentResponse.pending();
}
Phase 9: Observability
Structured Logging
// logback-spring.xml
// Use JSON format in production
@Slf4j
public class OrderService {
public Order processOrder(CreateOrderRequest request) {
try (var mdc = MDC.putCloseable("orderId", request.orderId());
var userMdc = MDC.putCloseable("userId", request.userId())) {
log.info("Processing order: items={}, total={}",
request.items().size(), request.total());
// All logs within this scope include orderId + userId
}
}
}
Metrics with Micrometer
@Component
@RequiredArgsConstructor
public class OrderMetrics {
private final MeterRegistry registry;
public void recordOrderProcessed(String status, Duration duration) {
registry.counter("orders.processed", "status", status).increment();
registry.timer("orders.processing.time", "status", status)
.record(duration);
}
public void recordActiveOrders(int count) {
registry.gauge("orders.active", count);
}
}
Health Indicators
@Component
public class PaymentServiceHealthIndicator implements HealthIndicator {
private final PaymentClient paymentClient;
@Override
public Health health() {
try {
paymentClient.ping();
return Health.up().withDetail("latency", "ok").build();
} catch (Exception e) {
return Health.down().withException(e).build();
}
}
}
Phase 10: Testing
Test Pyramid
| Level | What | Tools | Coverage Target |
|---|---|---|---|
| Unit | Services, mappers, utils | JUnit 5 + Mockito | 80% |
| Slice | Controllers, repositories | @WebMvcTest, @DataJpaTest | Key paths |
| Integration | Full flow with real DB | @SpringBootTest + Testcontainers | Happy + error |
| Contract | API contracts | Spring Cloud Contract / Pact | All endpoints |
Unit Test Pattern
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock UserRepository userRepository;
@Mock ApplicationEventPublisher eventPublisher;
@InjectMocks UserService userService;
@Test
void create_validRequest_savesAndPublishesEvent() {
var request = new CreateUserRequest("test@example.com", "Test User", UserRole.USER);
var savedUser = User.builder().id(1L).email(request.email()).build();
when(userRepository.existsByEmail(request.email())).thenReturn(false);
when(userRepository.save(any(User.class))).thenReturn(savedUser);
User result = userService.create(request);
assertThat(result.getId()).isEqualTo(1L);
verify(eventPublisher).publishEvent(any(UserCreatedEvent.class));
}
@Test
void create_duplicateEmail_throwsConflict() {
var request = new CreateUserRequest("existing@example.com", "Test", UserRole.USER);
when(userRepository.existsByEmail(request.email())).thenReturn(true);
assertThatThrownBy(() -> userService.create(request))
.isInstanceOf(ConflictException.class)
.hasMessageContaining("already registered");
}
}
Controller Slice Test
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class UserControllerTest {
@Autowired MockMvc mockMvc;
@MockBean UserService userService;
@MockBean UserMapper userMapper;
@Test
@WithMockUser(roles = "USER")
void getUser_exists_returns200() throws Exception {
var user = User.builder().id(1L).email("test@test.com").build();
var response = new UserResponse(1L, "test@test.com", "Test", UserStatus.ACTIVE, Instant.now());
when(userService.findById(1L)).thenReturn(user);
when(userMapper.toResponse(user)).thenReturn(response);
mockMvc.perform(get("/api/v1/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("test@test.com"));
}
}
Integration Test with Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class UserIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@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 fullUserLifecycle() {
// Create
var createReq = new CreateUserRequest("int@test.com", "Integration", UserRole.USER);
var created = restTemplate.postForEntity("/api/v1/users", createReq, UserResponse.class);
assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Read
var fetched = restTemplate.getForEntity(
"/api/v1/users/" + created.getBody().id(), UserResponse.class);
assertThat(fetched.getBody().email()).isEqualTo("int@test.com");
}
}
7 Testing Rules
- Constructor injection enables easy mocking — no reflection hacks
- Use
@WebMvcTestfor controller tests — loads only web layer - Use
@DataJpaTestfor repository tests — auto-configures JPA + rollback - Testcontainers for integration tests — real Postgres/Redis, not H2
- Test security —
@WithMockUser,@WithAnonymousUser - Test validation — ensure
@Validrejects bad input - Don't test framework code — test YOUR logic, not Spring's
Phase 11: Performance Optimization
Priority Stack
| # | Technique | Impact | Effort |
|---|---|---|---|
| 1 | Fix N+1 queries (JOIN FETCH / EntityGraph) | ★★★★★ | Low |
| 2 | Add database indexes on filtered/sorted columns | ★★★★★ | Low |
| 3 | Connection pool tuning (HikariCP) | ★★★★☆ | Low |
| 4 | Redis caching for read-heavy data | ★★★★☆ | Medium |
| 5 | DTO projections instead of full entities | ★★★★☆ | Medium |
| 6 | Async processing for non-critical tasks (@Async) | ★★★☆☆ | Medium |
| 7 | Virtual threads (Java 21+) for I/O-bound workloads | ★★★☆☆ | Low |
| 8 | GraalVM native compilation for cold start | ★★★☆☆ | High |
Virtual Threads (Java 21+)
# application.yml — enable virtual threads
spring:
threads:
virtual:
enabled: true # Tomcat uses virtual threads for requests
Async Processing
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
}
@Async
public CompletableFuture<Report> generateReport(Long userId) {
// Runs on thread pool, doesn't block request thread
Report report = reportGenerator.generate(userId);
return CompletableFuture.completedFuture(report);
}
Phase 12: Deployment
Multi-Stage Dockerfile
# Build
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY gradle/ gradle/
COPY gradlew build.gradle.kts settings.gradle.kts ./
RUN ./gradlew dependencies --no-daemon # Cache deps
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test
# Runtime
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
USER app
EXPOSE 8080
# JVM tuning for containers
ENV JAVA_OPTS="-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=50.0 \
-XX:+UseG1GC \
-XX:+ExitOnOutOfMemoryError \
-Djava.security.egd=file:/dev/./urandom"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
GitHub Actions CI/CD
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: gradle
- name: Build & Test
run: ./gradlew build
env:
DATABASE_URL: jdbc:postgresql://localhost:5432/testdb
DATABASE_USERNAME: test
DATABASE_PASSWORD: test
- name: Build Docker Image
if: github.ref == 'refs/heads/main'
run: |
docker build -t ${{ secrets.REGISTRY }}/app:${{ github.sha }} .
docker push ${{ secrets.REGISTRY }}/app:${{ github.sha }}
Production Readiness Checklist
P0 — Mandatory:
-
open-in-view: false -
ddl-auto: validate+ Flyway/Liquibase migrations - HikariCP pool configured with leak detection
- Graceful shutdown enabled
- Health + readiness endpoints exposed
- Global exception handler (no stack traces in responses)
- Input validation on all request DTOs
- Security configured (auth, CORS, headers)
- Structured JSON logging
- Prometheus metrics exported
P1 — Within 30 days:
- Circuit breakers on external calls
- Redis caching for hot paths
- Virtual threads enabled (Java 21+)
- Container resource limits set
- Dependency vulnerability scanning in CI
Phase 13: Kotlin-Specific Patterns
If using Kotlin instead of Java:
// Coroutines + WebFlux
@RestController
@RequestMapping("/api/v1/users")
class UserController(private val userService: UserService) {
@GetMapping("/{id}")
suspend fun getUser(@PathVariable id: Long): UserResponse =
userService.findById(id).toResponse()
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
suspend fun createUser(@Valid @RequestBody request: CreateUserRequest): UserResponse =
userService.create(request).toResponse()
}
// Data classes as DTOs (no Lombok needed)
data class CreateUserRequest(
@field:NotBlank @field:Email
val email: String,
@field:NotBlank @field:Size(min = 2, max = 100)
val name: String,
)
// Extension functions for mapping
fun User.toResponse() = UserResponse(
id = id,
email = email,
name = name,
status = status,
createdAt = createdAt,
)
Kotlin advantages: null safety, data classes (no Lombok), coroutines for async, extension functions for mapping, sealed classes for error hierarchies.
Phase 14: Advanced Patterns
Scheduled Jobs
@Component
@RequiredArgsConstructor
@Slf4j
public class CleanupJob {
private final UserRepository userRepository;
@Scheduled(cron = "0 0 2 * * *") // 2 AM daily
@SchedulerLock(name = "cleanup", lockAtMostFor = "30m") // ShedLock for distributed
public void cleanupInactiveUsers() {
int count = userRepository.deactivateInactiveUsers(
UserStatus.INACTIVE,
Instant.now().minus(90, ChronoUnit.DAYS)
);
log.info("Deactivated {} inactive users", count);
}
}
Kafka Integration
@Component
@RequiredArgsConstructor
public class OrderEventProducer {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
public void publishOrderCreated(Order order) {
var event = new OrderEvent("ORDER_CREATED", order.getId(), Instant.now());
kafkaTemplate.send("orders", order.getId().toString(), event);
}
}
@Component
@KafkaListener(topics = "orders", groupId = "notification-service")
public class OrderEventConsumer {
@KafkaHandler
public void handleOrderEvent(OrderEvent event) {
// Process event with idempotency check
}
}
Multi-Tenancy
@Component
public class TenantFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String tenantId = request.getHeader("X-Tenant-ID");
if (tenantId != null) {
TenantContext.setTenantId(tenantId);
}
try {
chain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
}
10 Common Mistakes
| # | Mistake | Fix |
|---|---|---|
| 1 | open-in-view: true (default!) | Set false — prevents lazy loading outside transaction |
| 2 | ddl-auto: update in production | Use Flyway/Liquibase — predictable, reversible migrations |
| 3 | Field injection (@Autowired) | Constructor injection — testable, explicit dependencies |
| 4 | Returning JPA entities from controllers | Use DTOs — prevents lazy loading errors + data leaks |
| 5 | Not configuring HikariCP | Tune pool size, timeouts, leak detection |
| 6 | Catching Exception everywhere | Specific exceptions + global handler |
| 7 | No pagination on list endpoints | Always paginate — Pageable parameter |
| 8 | Blocking calls in reactive stack | Don't mix blocking JPA with WebFlux |
| 9 | Missing @Transactional(readOnly=true) | Optimizes read queries (no dirty checking) |
| 10 | Testing with H2 instead of real DB | Testcontainers — H2 hides real SQL issues |
Quality Rubric (0-100)
| Dimension | Weight | Criteria |
|---|---|---|
| Architecture | 15% | Clean layers, DI, no circular deps |
| Data Access | 15% | N+1 free, indexed, migrations managed |
| Security | 15% | Auth, validation, headers, secrets management |
| Testing | 15% | Pyramid coverage, Testcontainers, slice tests |
| API Design | 10% | Consistent errors, pagination, OpenAPI docs |
| Observability | 10% | Structured logs, metrics, health checks |
| Resilience | 10% | Circuit breakers, retries, graceful shutdown |
| Deployment | 10% | Containerized, CI/CD, zero-downtime |
10 Commandments of Spring Boot Production
- Disable
open-in-view— first thing, every project - Constructor injection, always —
@RequiredArgsConstructor - DTOs at every boundary — controllers never touch entities
@Transactional(readOnly=true)by default — opt-in to writes- Testcontainers over H2 — test against real databases
- Flyway for migrations — never
ddl-auto: update - Validate all input —
@Validon every@RequestBody - Structure your logs — JSON in production, MDC for context
- Tune HikariCP — pool size = (core_count * 2) + spindle_count
- Enable graceful shutdown —
server.shutdown: graceful
Natural Language Commands
When working with Spring Boot projects, you can ask:
review my Spring Boot app→ Full architecture + config auditcheck my JPA entities→ N+1, indexing, mapping reviewreview my security config→ Auth, CORS, headers, vulnerabilitiesoptimize my queries→ N+1 detection, projection opportunitiesset up Testcontainers→ Integration test configurationadd caching→ Redis setup + cache strategyadd circuit breaker→ Resilience4j configurationDockerize my app→ Multi-stage Dockerfile + CI/CDadd observability→ Actuator + Prometheus + structured loggingreview my tests→ Coverage gaps, missing slice testsmigrate to Java 21→ Virtual threads, pattern matching, recordsconvert to Kotlin→ Coroutines, data classes, extension functions
⚡ Level Up Your Spring Boot Skills
This free skill covers production engineering methodology. For industry-specific AI agent context that accelerates your Spring Boot projects:
- SaaS Context Pack ($47) — SaaS billing, multi-tenancy, subscription management patterns
- Fintech Context Pack ($47) — Payment processing, compliance, financial data patterns
- Healthcare Context Pack ($47) — HIPAA compliance, HL7/FHIR, audit logging patterns
🔗 More Free Skills by AfrexAI
afrexai-python-production— Python production engineeringafrexai-api-architecture— API design & architectureafrexai-database-engineering— Database optimization & scalingafrexai-test-automation-engineering— Test strategy & automationafrexai-cicd-engineering— CI/CD pipeline engineering
Browse all: AfrexAI on ClawHub | Context Packs Storefront