Skip to content
Back to skills

Afrexai Spring Boot Production

ASecurity

> Complete production engineering methodology for Spring Boot & Java/Kotlin applications — architecture, security, observability, testing, deployment, and performance optimization.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 18, 2026
devopspythongojavakotlinsqlreactspringdockerkubernetestesting

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 18, 2026

npx -y skills add johnalbertini14-glitch/openclaw-skills --skill afrexai-spring-boot-production --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Afrexai Spring Boot Production?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Afrexai Spring Boot Production
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/johnalbertini14-glitch-afrexai-spring-boot-production/badge)](https://www.skillsdirectory.com/skills/johnalbertini14-glitch-afrexai-spring-boot-production)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

SKILL.md
# 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

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

```yaml
# 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

```yaml
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

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 shutdown** — `server.shutdown: graceful` + timeout

---

## Phase 3: JPA & Database Patterns

### Entity Design

```java
@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

```java
// ❌ 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

```java
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

```sql
-- 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 entities** — `SELECT 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

```java
@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

```java
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

```java
@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

```java
@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

```java
@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

```java
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

```java
@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

```java
@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

```yaml
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

```java
@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

```java
// 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

```java
@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

```java
@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

```java
@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

```java
@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

```java
@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

| # | 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+)

```yaml
# application.yml — enable virtual threads
spring:
  threads:
    virtual:
      enabled: true  # Tomcat uses virtual threads for requests
```

### Async Processing

```java
@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

```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

```yaml
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:

```kotlin
// 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

```java
@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

```java
@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

```java
@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

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 shutdown** — `server.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:

- **[SaaS Context Pack ($47)](https://afrexai-cto.github.io/context-packs/)** — SaaS billing, multi-tenancy, subscription management patterns
- **[Fintech Context Pack ($47)](https://afrexai-cto.github.io/context-packs/)** — Payment processing, compliance, financial data patterns
- **[Healthcare Context Pack ($47)](https://afrexai-cto.github.io/context-packs/)** — HIPAA compliance, HL7/FHIR, audit logging patterns

## 🔗 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](https://clawhub.com) | [Context Packs Storefront](https://afrexai-cto.github.io/context-packs/)

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.