Spring Boot Production Engineering

# Spring Boot Production Engineering

Safety Notice

This listing is from the official public ClawHub registry. Review SKILL.md and referenced scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "Spring Boot Production Engineering" with this command: npx skills add 1kalin/afrexai-spring-boot-production

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):

SignalCheck/2
🏗️ ArchitectureClean layered architecture with dependency injection?
🔒 SecuritySpring Security configured with proper auth + CORS + CSRF?
📊 ObservabilityStructured logging + metrics + health endpoints?
🧪 TestingUnit + integration + slice tests with >70% coverage?
⚡ PerformanceConnection pooling + caching + async where appropriate?
🚀 DeploymentContainerized with CI/CD + zero-downtime deploys?
📝 API DesignOpenAPI docs + versioning + consistent error responses?
🛡️ ResilienceCircuit 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

  1. Controllers are thin — validate input, call service, return DTO. No business logic.
  2. Services own business logic — transaction boundaries live here.
  3. Repositories are interfaces — Spring Data generates implementations.
  4. DTOs at boundaries — never expose JPA entities in API responses.
  5. Constructor injection only — no @Autowired on fields (testability).
  6. Package by feature for large apps — when >20 services, switch from layer-based to feature-based.
  7. 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

FactorSpring BootQuarkusMicronautKtor (Kotlin)
Startup time2-5s0.5-1s1-2s1-2s
Memory200-400MB50-150MB100-200MB80-150MB
Ecosystem★★★★★★★★☆☆★★★☆☆★★☆☆☆
Enterprise adoptionDominantGrowingNicheNiche
Native compilationGraalVM (complex)Native (easy)Native (easy)GraalVM
Team hiringEasyHardHardHard

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

ProfilePurposeConfig
localDevelopmentH2/local Postgres, debug logging
testTestingTestcontainers, no external deps
stagingPre-productionReal deps, reduced resources
productionLiveFull resources, minimal logging

Configuration Rules

  1. Never hardcode secrets — always use environment variables or vault
  2. Disable open-in-view — prevents lazy loading in controller layer (performance killer)
  3. Set ddl-auto: validate in production — use Flyway/Liquibase for migrations
  4. Configure HikariCP explicitly — defaults are often wrong for production
  5. Enable graceful shutdownserver.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

  1. Always use FetchType.LAZY — eager loading causes N+1
  2. Use @Version for optimistic locking — prevents lost updates
  3. Prefer projections over full entitiesSELECT new DTO(...) for read-only
  4. Batch inserts/updates — configure batch_size + order_inserts
  5. Never use ddl-auto: update in production — Flyway/Liquibase only
  6. Use @NaturalId for business keys — email, ISBN, etc.
  7. Avoid bidirectional mappings unless needed — more complexity, more bugs
  8. 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

#CheckPriority
1CSRF disabled for stateless APIs, enabled for session-basedP0
2CORS configured with specific origins (no wildcards in prod)P0
3JWT validation with proper issuer/audience checksP0
4Input validation on all request DTOs (@Valid)P0
5SQL injection prevention (parameterized queries only)P0
6Secrets in environment variables or vault (never in code)P0
7Security headers (CSP, X-Frame-Options, HSTS)P1
8Rate limiting on auth endpointsP1
9Dependency vulnerability scanning (OWASP, Snyk)P1
10Method-level security (@PreAuthorize) for sensitive operationsP1

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

LevelWhatToolsCoverage Target
UnitServices, mappers, utilsJUnit 5 + Mockito80%
SliceControllers, repositories@WebMvcTest, @DataJpaTestKey paths
IntegrationFull flow with real DB@SpringBootTest + TestcontainersHappy + error
ContractAPI contractsSpring Cloud Contract / PactAll 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

  1. Constructor injection enables easy mocking — no reflection hacks
  2. Use @WebMvcTest for controller tests — loads only web layer
  3. Use @DataJpaTest for repository tests — auto-configures JPA + rollback
  4. Testcontainers for integration tests — real Postgres/Redis, not H2
  5. Test security@WithMockUser, @WithAnonymousUser
  6. Test validation — ensure @Valid rejects bad input
  7. Don't test framework code — test YOUR logic, not Spring's

Phase 11: Performance Optimization

Priority Stack

#TechniqueImpactEffort
1Fix N+1 queries (JOIN FETCH / EntityGraph)★★★★★Low
2Add database indexes on filtered/sorted columns★★★★★Low
3Connection pool tuning (HikariCP)★★★★☆Low
4Redis caching for read-heavy data★★★★☆Medium
5DTO projections instead of full entities★★★★☆Medium
6Async processing for non-critical tasks (@Async)★★★☆☆Medium
7Virtual threads (Java 21+) for I/O-bound workloads★★★☆☆Low
8GraalVM 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

#MistakeFix
1open-in-view: true (default!)Set false — prevents lazy loading outside transaction
2ddl-auto: update in productionUse Flyway/Liquibase — predictable, reversible migrations
3Field injection (@Autowired)Constructor injection — testable, explicit dependencies
4Returning JPA entities from controllersUse DTOs — prevents lazy loading errors + data leaks
5Not configuring HikariCPTune pool size, timeouts, leak detection
6Catching Exception everywhereSpecific exceptions + global handler
7No pagination on list endpointsAlways paginate — Pageable parameter
8Blocking calls in reactive stackDon't mix blocking JPA with WebFlux
9Missing @Transactional(readOnly=true)Optimizes read queries (no dirty checking)
10Testing with H2 instead of real DBTestcontainers — H2 hides real SQL issues

Quality Rubric (0-100)

DimensionWeightCriteria
Architecture15%Clean layers, DI, no circular deps
Data Access15%N+1 free, indexed, migrations managed
Security15%Auth, validation, headers, secrets management
Testing15%Pyramid coverage, Testcontainers, slice tests
API Design10%Consistent errors, pagination, OpenAPI docs
Observability10%Structured logs, metrics, health checks
Resilience10%Circuit breakers, retries, graceful shutdown
Deployment10%Containerized, CI/CD, zero-downtime

10 Commandments of Spring Boot Production

  1. Disable open-in-view — first thing, every project
  2. Constructor injection, always@RequiredArgsConstructor
  3. DTOs at every boundary — controllers never touch entities
  4. @Transactional(readOnly=true) by default — opt-in to writes
  5. Testcontainers over H2 — test against real databases
  6. Flyway for migrations — never ddl-auto: update
  7. Validate all input@Valid on every @RequestBody
  8. Structure your logs — JSON in production, MDC for context
  9. Tune HikariCP — pool size = (core_count * 2) + spindle_count
  10. Enable graceful shutdownserver.shutdown: graceful

Natural Language Commands

When working with Spring Boot projects, you can ask:

  1. review my Spring Boot app → Full architecture + config audit
  2. check my JPA entities → N+1, indexing, mapping review
  3. review my security config → Auth, CORS, headers, vulnerabilities
  4. optimize my queries → N+1 detection, projection opportunities
  5. set up Testcontainers → Integration test configuration
  6. add caching → Redis setup + cache strategy
  7. add circuit breaker → Resilience4j configuration
  8. Dockerize my app → Multi-stage Dockerfile + CI/CD
  9. add observability → Actuator + Prometheus + structured logging
  10. review my tests → Coverage gaps, missing slice tests
  11. migrate to Java 21 → Virtual threads, pattern matching, records
  12. convert 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:

🔗 More Free Skills by AfrexAI

  • afrexai-python-production — Python production engineering
  • afrexai-api-architecture — API design & architecture
  • afrexai-database-engineering — Database optimization & scaling
  • afrexai-test-automation-engineering — Test strategy & automation
  • afrexai-cicd-engineering — CI/CD pipeline engineering

Browse all: AfrexAI on ClawHub | Context Packs Storefront

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Security

Source Code Security Review

Perform a systematic white-box security review of web application source code to find exploitable vulnerabilities. Use this skill when: you have authorized a...

Registry SourceRecently Updated
1170Profile unavailable
Security

Ai Company V1.0.4 Temp

Unified AI Company skill consolidating 16 department skills into one. Provides complete governance, finance, technology, security, legal, people, marketing,...

Registry SourceRecently Updated
3280Profile unavailable
Security

Workflow Audit

Conduct a structured operational audit — identify friction points, map workflows, quantify waste, and produce a priority-ranked automation blueprint with ROI...

Registry SourceRecently Updated
1970Profile unavailable
Security

GauntletScore

Trust verification for AI output — verify any document or code before you act on it

Registry SourceRecently Updated
4140Profile unavailable