MFormations
Modern Java Engineering

Chapitre 7

Chapitre 07 : Spring Boot

Chapitre 07 : Spring Boot

Cours : Spring Boot

1. Auto-configuration et Starters

1.1 Principe

Spring Boot automatise la configuration via :

  • Starters : dépendances pré-configurées
  • Auto-configuration : @ConditionalOnClass, @ConditionalOnMissingBean
  • application.yml : propriétés externalisées

1.2 Starters Essentiels

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <!-- Tomcat + Spring MVC + Jackson + Validation -->
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    <!-- Hibernate + HikariCP + Spring Data JPA -->
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <!-- JUnit 5 + Mockito + AssertJ + Hamcrest -->
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
    <!-- Health, Metrics, Info, Env endpoints -->
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    <!-- Hibernate Validator, Bean Validation -->
</dependency>

1.3 Auto-configuration

// Voir ce qui est configuré automatiquement
// Ajouter dans application.yml :
// debug: true
// Ou utiliser le endpoint /actuator/conditions

// Désactiver une auto-configuration
@SpringBootApplication(exclude = {
    DataSourceAutoConfiguration.class,
    HibernateJpaAutoConfiguration.class
})
public class Application { }

// Condition personnalisée
@Configuration
@ConditionalOnClass(name = "org.postgresql.Driver")
@ConditionalOnProperty(name = "app.datasource.type", havingValue = "postgres")
public class PostgresDataSourceConfig { }

2. @SpringBootApplication

// Équivalent à :
// @Configuration
// @EnableAutoConfiguration
// @ComponentScan

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        // Lance Spring Boot
        SpringApplication.run(Application.class, args);
    }
}

// Customisation
@SpringBootApplication
public class CustomApp {
    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(CustomApp.class);
        app.setDefaultProperties(Map.of("server.port", "8081"));
        app.setBannerMode(Banner.Mode.OFF);
        app.setAdditionalProfiles("dev");
        app.run(args);
    }
}

// ApplicationRunner (au démarrage)
@Component
public class StartupRunner implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) {
        System.out.println("Application démarrée avec: " + Arrays.toString(args.getSourceArgs()));
    }
}

// CommandLineRunner
@Component
@Order(1)
public class DataInitializer implements CommandLineRunner {
    @Override
    public void run(String... args) {
        System.out.println("Initialisation des données");
    }
}

3. Configuration

3.1 application.yml

# application.yml (recommandé)
server:
  port: 8080
  servlet:
    context-path: /api

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: user
    password: ${DB_PASSWORD} # Variable d'environnement
    hikari:
      maximum-pool-size: 10
      minimum-idle: 2
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false
    open-in-view: false # Important: désactiver en prod
  jackson:
    serialization:
      write-dates-as-timestamps: false
    default-property-inclusion: non_null

app:
  name: Modern Java Engineering
  version: 1.0.0
  features:
    caching: true
    analytics: false
  supported-locales:
    - fr
    - en
    - es

logging:
  level:
    com.example: DEBUG
    org.springframework: WARN
    org.hibernate: ERROR

3.2 Profils

# application-dev.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb
  jpa:
    show-sql: true
  devtools:
    livereload:
      enabled: true

# application-prod.yml
spring:
  datasource:
    url: jdbc:postgresql://prod-server:5432/proddb
    hikari:
      maximum-pool-size: 50
  jpa:
    show-sql: false

3.3 @ConfigurationProperties

// application.yml :
// app:
//   name: MyApp
//   features:
//     caching: true

@ConfigurationProperties(prefix = "app")
@Component
public class AppProperties {
    private String name;
    private String version;
    private Features features = new Features();
    private List<String> supportedLocales = new ArrayList<>();
    
    // getters/setters
    
    public static class Features {
        private boolean caching;
        private boolean analytics;
        // getters/setters
    }
}

// Activer @ConfigurationPropertiesScan sur la configuration
@SpringBootApplication
@ConfigurationPropertiesScan
public class Application { }

4. Actuator

4.1 Configuration

# Exposer tous les endpoints
management:
  endpoints:
    web:
      exposure:
        include: "*"
        exclude: "shutdown"
  endpoint:
    health:
      show-details: always
    info:
      enabled: true
    metrics:
      enabled: true
  metrics:
    export:
      prometheus:
        enabled: true

4.2 Endpoints

EndpointDescription
/actuator/healthSanté de l'application
/actuator/infoInformations custom
/actuator/metricsMétriques JVM, système
/actuator/envPropriétés d'environnement
/actuator/beansTous les beans Spring
/actuator/conditionsConditions d'auto-configuration
/actuator/mappingsEndpoints HTTP
/actuator/loggersConfiguration des logs
/actuator/threaddumpThread dump
/actuator/heapdumpHeap dump
/actuator/prometheusMétriques Prometheus

4.3 Health Indicators

@Component
public class DatabaseHealthIndicator implements HealthIndicator {
    private final DataSource dataSource;
    
    public DatabaseHealthIndicator(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    
    @Override
    public Health health() {
        try (Connection conn = dataSource.getConnection()) {
            if (conn.isValid(5)) {
                return Health.up()
                    .withDetail("database", "PostgreSQL")
                    .withDetail("status", "CONNECTED")
                    .build();
            }
            return Health.down()
                .withDetail("database", "PostgreSQL")
                .withDetail("status", "NOT RESPONDING")
                .build();
        } catch (SQLException e) {
            return Health.down(e)
                .withDetail("database", "PostgreSQL")
                .build();
        }
    }
}

// Info endpoint
@Component
public class AppInfoContributor implements InfoContributor {
    @Override
    public void contribute(Info.Builder builder) {
        builder
            .withDetail("app", Map.of(
                "name", "Modern Java Engineering",
                "version", "1.0.0",
                "java-version", Runtime.version().toString()
            ))
            .withDetail("build", Map.of(
                "timestamp", Instant.now().toString()
            ));
    }
}

4.4 Métriques Custom

@Component
public class OrderMetrics {
    private final MeterRegistry meterRegistry;
    private final Counter orderCounter;
    private final Timer orderTimer;
    
    public OrderMetrics(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.orderCounter = Counter.builder("orders.created")
            .description("Nombre de commandes créées")
            .register(meterRegistry);
        this.orderTimer = Timer.builder("orders.processing")
            .description("Temps de traitement des commandes")
            .register(meterRegistry);
    }
    
    public void recordOrder() {
        orderCounter.increment();
    }
    
    public <T> T measureOrder(Runnable runnable) {
        return orderTimer.record(runnable);
    }
}

5. REST Controllers

5.1 CRUD Controller

@RestController
@RequestMapping("/api/users")
public class UserController {
    private final UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;
    }
    
    @GetMapping
    public ResponseEntity<List<UserDTO>> getAllUsers(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        return ResponseEntity.ok(userService.findAll(page, size));
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<UserDTO> getUser(@PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }
    
    @PostMapping
    public ResponseEntity<UserDTO> createUser(@Valid @RequestBody CreateUserRequest request) {
        UserDTO created = userService.create(request);
        return ResponseEntity.created(URI.create("/api/users/" + created.getId()))
            .body(created);
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<UserDTO> updateUser(@PathVariable Long id, 
                                               @Valid @RequestBody UpdateUserRequest request) {
        return ResponseEntity.ok(userService.update(id, request));
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

// DTOs
public record UserDTO(Long id, String name, String email, LocalDateTime createdAt) {}
public record CreateUserRequest(@NotBlank String name, @Email String email) {}
public record UpdateUserRequest(@NotBlank String name) {}

5.2 Query Parameters

@RestController
@RequestMapping("/api/search")
public class SearchController {
    
    @GetMapping("/users")
    public ResponseEntity<List<UserDTO>> searchUsers(
            @RequestParam(required = false) String name,
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "name,asc") String[] sort) {
        
        Sort sorting = Sort.by(Arrays.stream(sort)
            .map(s -> s.split(","))
            .map(arr -> new Sort.Order(
                Sort.Direction.fromString(arr[1]), arr[0]))
            .toList());
        
        return ResponseEntity.ok(userService.search(name, fromDate, 
            PageRequest.of(page, size, sorting)));
    }
}

5.3 Response Status

@RestController
public class StatusController {
    
    @GetMapping("/ping")
    @ResponseStatus(HttpStatus.OK)
    public Map<String, String> ping() {
        return Map.of("status", "OK", "timestamp", Instant.now().toString());
    }
    
    @PostMapping("/items")
    @ResponseStatus(HttpStatus.CREATED)
    public Item createItem(@Valid @RequestBody Item item) {
        return itemService.create(item);
    }
}

6. Validation

6.1 Bean Validation

// Request DTO
public record CreateProductRequest(
    @NotBlank(message = "Le nom est obligatoire")
    @Size(min = 2, max = 100)
    String name,
    
    @NotNull
    @Positive(message = "Le prix doit être positif")
    @DecimalMin("0.01")
    BigDecimal price,
    
    @Min(0)
    @Max(100000)
    int stock,
    
    @Email(regexp = ".+@.+\\..+", message = "Format email invalide")
    String supplierEmail,
    
    @Past
    LocalDate manufactureDate,
    
    @Future
    LocalDate expiryDate,
    
    @Pattern(regexp = "^[A-Z]{2}\\d{4}$", message = "Format: AA1234")
    String productCode
) {}

// Validation groupe
public interface Create {}
public interface Update {}

public record ProductDTO(
    @Null(groups = Create.class)
    @NotNull(groups = Update.class)
    Long id,
    
    @NotBlank(groups = {Create.class, Update.class})
    String name
) {}

// Controller
@RestController
@RequestMapping("/api/products")
public class ProductController {
    
    @PostMapping
    public ResponseEntity<ProductDTO> create(
            @Validated(Create.class) @RequestBody ProductDTO dto) {
        return ResponseEntity.status(201).body(service.create(dto));
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<ProductDTO> update(
            @PathVariable Long id,
            @Validated(Update.class) @RequestBody ProductDTO dto) {
        return ResponseEntity.ok(service.update(id, dto));
    }
}

6.2 Validation Custom

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
    String message() default "Email déjà utilisé";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
    private final UserRepository userRepository;
    
    public UniqueEmailValidator(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
    
    @Override
    public boolean isValid(String email, ConstraintValidatorContext context) {
        return !userRepository.existsByEmail(email);
    }
}

// Utilisation
public record CreateUserRequest(
    @NotBlank @Email @UniqueEmail
    String email
) {}

7. Error Handling

7.1 @ControllerAdvice

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    
    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
        return new ErrorResponse("NOT_FOUND", ex.getMessage());
    }
    
    @ExceptionHandler(ValidationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleValidation(ValidationException ex) {
        return new ErrorResponse("VALIDATION_ERROR", ex.getMessage());
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
        List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> new FieldError(fe.getField(), fe.getDefaultMessage()))
            .toList();
        return new ErrorResponse("VALIDATION_ERROR", "Erreur de validation", fieldErrors);
    }
    
    @ExceptionHandler(AccessDeniedException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public ErrorResponse handleAccessDenied(AccessDeniedException ex) {
        return new ErrorResponse("FORBIDDEN", "Accès refusé");
    }
    
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse handleGeneric(Exception ex) {
        log.error("Erreur non gérée: {}", ex.getMessage(), ex);
        return new ErrorResponse("INTERNAL_ERROR", "Erreur interne du serveur");
    }
    
    // DTO
    public record ErrorResponse(String code, String message, List<FieldError> errors) {
        public ErrorResponse(String code, String message) {
            this(code, message, List.of());
        }
    }
    
    public record FieldError(String field, String message) {}
}

7.2 Exception personnalisée

@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
    private final String resourceName;
    private final Object resourceId;
    
    public ResourceNotFoundException(String resourceName, Object resourceId) {
        super("%s non trouvé avec l'identifiant: %s".formatted(resourceName, resourceId));
        this.resourceName = resourceName;
        this.resourceId = resourceId;
    }
    
    public String getResourceName() { return resourceName; }
    public Object getResourceId() { return resourceId; }
}

8. Testing

8.1 Unit Test

@ExtendWith(MockitoExtension.class)
class UserServiceTest {
    
    @Mock
    private UserRepository userRepository;
    
    @InjectMocks
    private UserService userService;
    
    @Test
    void shouldFindUserById() {
        // Given
        var user = new User(1L, "Alice", "alice@test.com");
        when(userRepository.findById(1L)).thenReturn(Optional.of(user));
        
        // When
        var result = userService.findById(1L);
        
        // Then
        assertThat(result.name()).isEqualTo("Alice");
        assertThat(result.email()).isEqualTo("alice@test.com");
        verify(userRepository).findById(1L);
    }
    
    @Test
    void shouldThrowWhenUserNotFound() {
        when(userRepository.findById(999L)).thenReturn(Optional.empty());
        
        assertThatThrownBy(() -> userService.findById(999L))
            .isInstanceOf(ResourceNotFoundException.class)
            .hasMessageContaining("999");
    }
}

8.2 @WebMvcTest

@WebMvcTest(UserController.class)
class UserControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @Test
    void shouldReturnUser() throws Exception {
        var user = new UserDTO(1L, "Alice", "alice@test.com", LocalDateTime.now());
        when(userService.findById(1L)).thenReturn(user);
        
        mockMvc.perform(get("/api/users/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("Alice"))
            .andExpect(jsonPath("$.email").value("alice@test.com"))
            .andExpect(jsonPath("$.id").value(1));
    }
    
    @Test
    void shouldReturn404WhenUserNotFound() throws Exception {
        when(userService.findById(999L)).thenThrow(new ResourceNotFoundException("User", 999L));
        
        mockMvc.perform(get("/api/users/999"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.code").value("NOT_FOUND"));
    }
    
    @Test
    void shouldCreateUser() throws Exception {
        var request = new CreateUserRequest("Bob", "bob@test.com");
        var created = new UserDTO(2L, "Bob", "bob@test.com", LocalDateTime.now());
        when(userService.create(any())).thenReturn(created);
        
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"name": "Bob", "email": "bob@test.com"}
                    """))
            .andExpect(status().isCreated())
            .andExpect(header().exists("Location"))
            .andExpect(jsonPath("$.name").value("Bob"));
    }
}

8.3 @DataJpaTest

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest {
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Test
    void shouldFindByEmail() {
        var user = new User("Alice", "alice@test.com");
        entityManager.persistAndFlush(user);
        
        var found = userRepository.findByEmail("alice@test.com");
        
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("Alice");
    }
    
    @Test
    void shouldReturnEmptyForUnknownEmail() {
        var found = userRepository.findByEmail("unknown@test.com");
        assertThat(found).isEmpty();
    }
}

8.4 @SpringBootTest

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class ApplicationIntegrationTest {
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Autowired
    private UserRepository userRepository;
    
    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
    }
    
    @Test
    void fullUserLifecycle() {
        // Create
        var createResponse = restTemplate.postForEntity("/api/users",
            new CreateUserRequest("Alice", "alice@test.com"),
            UserDTO.class);
        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        var created = createResponse.getBody();
        
        // Read
        var getResponse = restTemplate.getForEntity("/api/users/" + created.id(), UserDTO.class);
        assertThat(getResponse.getBody().name()).isEqualTo("Alice");
        
        // Delete
        restTemplate.delete("/api/users/" + created.id());
        
        // Verify
        assertThat(userRepository.findById(created.id())).isEmpty();
    }
}

8.5 @TestConfiguration

@SpringBootTest
class NotificationServiceTest {
    
    @Autowired
    private NotificationService notificationService;
    
    @TestConfiguration
    static class TestConfig {
        @Bean
        @Primary
        public EmailService mockEmailService() {
            return mock(EmailService.class);
        }
    }
    
    @Test
    void shouldSendNotification() {
        notificationService.send("test@test.com", "Message");
        // Vérification
    }
}

9. Résumé

ConceptOutil
DépendancesStarters
Configurationapplication.yml + @ConfigurationProperties
API REST@RestController
Validation@Valid + Bean Validation
Erreurs@ControllerAdvice
MonitoringActuator
Tests@WebMvcTest, @DataJpaTest
Profilsapplication-{profile}.yml