30 questions d'entretien Spring Boot : Guide complet pour développeurs Java
Préparez vos entretiens Spring Boot avec ces 30 questions essentielles couvrant l'auto-configuration, les starters, Spring Data JPA, la sécurité et les tests.

Spring Boot est devenu le framework de référence pour le développement d'applications Java en entreprise. Les entretiens techniques évaluent la compréhension des mécanismes internes, des bonnes pratiques et de l'écosystème Spring. Ce guide couvre les 30 questions les plus fréquentes, des concepts fondamentaux aux aspects avancés.
Les recruteurs valorisent les candidats qui comprennent le "pourquoi" derrière chaque fonctionnalité. Au-delà de la syntaxe, expliquez les problèmes résolus par Spring Boot.
Fondamentaux Spring Boot
1. Quelle est la différence entre Spring et Spring Boot ?
Spring est un framework modulaire offrant l'injection de dépendances, la gestion transactionnelle et l'intégration avec de nombreuses technologies. Spring Boot est une couche d'abstraction au-dessus de Spring qui simplifie la configuration et le démarrage des applications.
// Avec Spring Boot : une seule annotation pour démarrer
@SpringBootApplication
public class Application {
public static void main(String[] args) {
// SpringApplication configure et démarre le contexte Spring
SpringApplication.run(Application.class, args);
}
}Spring Boot apporte l'auto-configuration, les starters de dépendances, un serveur embarqué et les métriques de production via Actuator. L'objectif est de passer de l'idée au code fonctionnel en quelques minutes.
2. Comment fonctionne l'auto-configuration ?
L'auto-configuration analyse le classpath et configure automatiquement les beans nécessaires. Le mécanisme repose sur l'annotation @EnableAutoConfiguration (incluse dans @SpringBootApplication) et les conditions définies dans les classes d'auto-configuration.
@Configuration
@ConditionalOnClass(DataSource.class) // Active si DataSource est dans le classpath
@ConditionalOnMissingBean(DataSource.class) // N'agit que si aucun DataSource n'existe
public class CustomAutoConfiguration {
@Bean
@ConditionalOnProperty(name = "app.datasource.enabled", havingValue = "true")
public DataSource dataSource() {
// Configuration par défaut de la DataSource
return DataSourceBuilder.create()
.driverClassName("org.h2.Driver")
.url("jdbc:h2:mem:testdb")
.build();
}
}Spring Boot examine les conditions (@ConditionalOn*) pour déterminer quels beans créer. Cette approche évite les conflits et permet de surcharger facilement les configurations par défaut.
3. Qu'est-ce qu'un starter et comment en créer un ?
Un starter est un module Maven/Gradle regroupant les dépendances et configurations nécessaires pour une fonctionnalité. Par exemple, spring-boot-starter-web inclut Spring MVC, Jackson, Tomcat embarqué et la validation.
<!-- pom.xml d'un starter personnalisé -->
<project>
<artifactId>my-company-starter</artifactId>
<dependencies>
<!-- Dépendance Spring Boot de base -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Bibliothèques spécifiques à l'entreprise -->
<dependency>
<groupId>com.mycompany</groupId>
<artifactId>logging-utils</artifactId>
</dependency>
</dependencies>
</project>Pour créer un starter personnalisé, il faut définir les classes d'auto-configuration et les référencer dans META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.
4. Expliquez le fichier application.properties vs application.yml
Les deux formats servent à externaliser la configuration. YAML offre une syntaxe plus lisible pour les structures imbriquées, tandis que properties reste plus simple pour les configurations plates.
# application.properties
server.port=8080
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=admin
spring.jpa.hibernate.ddl-auto=update# application.yml - structure hiérarchique claire
server:
port: 8080
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: admin
jpa:
hibernate:
ddl-auto: updateLes profils permettent d'adapter la configuration selon l'environnement : application-dev.yml, application-prod.yml. L'activation se fait via spring.profiles.active.
5. Comment fonctionne @SpringBootApplication ?
Cette annotation combine trois annotations essentielles qui configurent le comportement de l'application Spring Boot.
// @SpringBootApplication est équivalent à ces trois annotations :
@SpringBootConfiguration // Équivalent à @Configuration
@EnableAutoConfiguration // Active l'auto-configuration
@ComponentScan // Scan les composants dans le package et sous-packages
public class DemoApplication {
public static void main(String[] args) {
// Crée le contexte, exécute les configurations et démarre le serveur
ConfigurableApplicationContext context =
SpringApplication.run(DemoApplication.class, args);
// Le contexte contient tous les beans configurés
UserService userService = context.getBean(UserService.class);
}
}Le placement de cette classe à la racine du package est crucial : le @ComponentScan scanne ce package et tous ses sous-packages pour trouver les composants.
Spring Data et Persistance
6. Quelle est la différence entre JpaRepository, CrudRepository et PagingAndSortingRepository ?
Ces interfaces forment une hiérarchie offrant des niveaux de fonctionnalités croissants pour l'accès aux données.
// CrudRepository : opérations CRUD de base
public interface UserCrudRepository extends CrudRepository<User, Long> {
// save(), findById(), findAll(), deleteById(), count(), existsById()
}
// PagingAndSortingRepository : ajoute pagination et tri
public interface UserPagingRepository extends PagingAndSortingRepository<User, Long> {
// Hérite de CrudRepository + findAll(Sort), findAll(Pageable)
}
// JpaRepository : fonctionnalités JPA complètes
public interface UserRepository extends JpaRepository<User, Long> {
// Hérite des précédents + flush(), saveAndFlush(), deleteInBatch()
// Méthodes dérivées du nom
List<User> findByEmailContaining(String email);
Optional<User> findByUsernameAndActiveTrue(String username);
}Pour la plupart des cas, JpaRepository est le choix recommandé car il offre toutes les fonctionnalités nécessaires au développement d'applications JPA.
7. Comment définir des requêtes personnalisées avec @Query ?
L'annotation @Query permet d'écrire des requêtes JPQL ou SQL natives lorsque les méthodes dérivées ne suffisent pas.
public interface OrderRepository extends JpaRepository<Order, Long> {
// JPQL avec paramètres nommés
@Query("SELECT o FROM Order o WHERE o.customer.id = :customerId AND o.status = :status")
List<Order> findCustomerOrdersByStatus(
@Param("customerId") Long customerId,
@Param("status") OrderStatus status
);
// Requête native SQL pour des besoins spécifiques
@Query(value = "SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '7 days'",
nativeQuery = true)
List<Order> findRecentOrders();
// Requête de modification avec @Modifying
@Modifying
@Transactional
@Query("UPDATE Order o SET o.status = :status WHERE o.id IN :ids")
int updateOrderStatuses(@Param("ids") List<Long> ids, @Param("status") OrderStatus status);
}Les requêtes JPQL sont portables entre bases de données, tandis que les requêtes natives offrent l'accès aux fonctionnalités spécifiques du SGBD.
8. Expliquez la gestion des transactions avec @Transactional
Cette annotation déclare les limites transactionnelles d'une méthode ou d'une classe. Spring gère automatiquement le commit, le rollback et la propagation.
@Service
public class PaymentService {
private final AccountRepository accountRepository;
private final TransactionLogRepository logRepository;
// Transaction avec configuration personnalisée
@Transactional(
propagation = Propagation.REQUIRED, // Crée ou rejoint une transaction
isolation = Isolation.READ_COMMITTED, // Niveau d'isolation
timeout = 30, // Timeout en secondes
rollbackFor = PaymentException.class // Rollback sur cette exception
)
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
Account from = accountRepository.findById(fromId)
.orElseThrow(() -> new AccountNotFoundException(fromId));
Account to = accountRepository.findById(toId)
.orElseThrow(() -> new AccountNotFoundException(toId));
from.debit(amount); // Exception si solde insuffisant
to.credit(amount);
accountRepository.save(from);
accountRepository.save(to);
// Le log sera rollbacké avec le reste si une exception survient
logRepository.save(new TransactionLog(fromId, toId, amount));
}
}Les niveaux de propagation (REQUIRED, REQUIRES_NEW, NESTED, etc.) définissent comment les transactions s'imbriquent lors d'appels de méthodes transactionnelles.
@Transactional ne fonctionne que pour les appels passant par le proxy Spring. Un appel interne à la même classe contourne le proxy et ignore l'annotation.
9. Comment gérer les relations Lazy vs Eager Loading ?
Le mode de chargement des relations impacte directement les performances. Lazy Loading diffère le chargement jusqu'à l'accès, Eager Loading charge immédiatement.
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Eager : les rôles sont chargés avec l'utilisateur
@ManyToMany(fetch = FetchType.EAGER)
private Set<Role> roles;
// Lazy : les commandes ne sont chargées qu'à l'accès
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Order> orders;
}@Service
public class UserService {
@Transactional(readOnly = true)
public UserDTO getUserWithOrders(Long userId) {
User user = userRepository.findById(userId).orElseThrow();
// L'accès à orders déclenche une requête SQL supplémentaire
// Cela fonctionne car la session est ouverte (@Transactional)
List<OrderDTO> orderDTOs = user.getOrders().stream()
.map(this::toDTO)
.collect(Collectors.toList());
return new UserDTO(user, orderDTOs);
}
// Alternative : requête avec JOIN FETCH pour éviter N+1
public UserDTO getUserWithOrdersOptimized(Long userId) {
User user = userRepository.findByIdWithOrders(userId);
// Les orders sont déjà chargés, pas de requête supplémentaire
return new UserDTO(user, user.getOrders());
}
}Le problème N+1 survient quand le chargement lazy génère une requête par élément de collection. JOIN FETCH ou les projections résolvent ce problème.
10. Qu'est-ce que le problème N+1 et comment le résoudre ?
Le problème N+1 se produit quand une requête initiale (1) est suivie de N requêtes supplémentaires pour charger les relations de chaque entité.
public interface ArticleRepository extends JpaRepository<Article, Long> {
// PROBLÈME : cette requête génère N+1 requêtes
List<Article> findAll();
// SOLUTION 1 : JOIN FETCH dans JPQL
@Query("SELECT a FROM Article a JOIN FETCH a.author JOIN FETCH a.comments")
List<Article> findAllWithDetails();
// SOLUTION 2 : Entity Graph
@EntityGraph(attributePaths = {"author", "comments"})
@Query("SELECT a FROM Article a")
List<Article> findAllWithGraph();
}@Entity
@NamedEntityGraph(
name = "Article.withDetails",
attributeNodes = {
@NamedAttributeNode("author"),
@NamedAttributeNode("comments")
}
)
public class Article {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Author author;
@OneToMany(mappedBy = "article", fetch = FetchType.LAZY)
private List<Comment> comments;
}L'utilisation de @EntityGraph ou JOIN FETCH réduit les requêtes de N+1 à une seule requête avec jointures, améliorant significativement les performances.
Prêt à réussir tes entretiens Spring Boot ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
API REST et Web
11. Comment créer un contrôleur REST avec validation ?
Spring Boot combine @RestController avec Bean Validation pour créer des API robustes avec validation automatique des entrées.
@RestController
@RequestMapping("/api/users")
@Validated // Active la validation sur les paramètres de méthode
public class UserController {
private final UserService userService;
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
// La validation est effectuée automatiquement avant l'exécution
return userService.createUser(request);
}
@GetMapping("/{id}")
public UserResponse getUser(
@PathVariable @Min(1) Long id // Validation sur PathVariable
) {
return userService.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
@GetMapping
public Page<UserResponse> listUsers(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Max(100) int size
) {
return userService.findAll(PageRequest.of(page, size));
}
}public record CreateUserRequest(
@NotBlank(message = "Le nom est obligatoire")
@Size(min = 2, max = 50, message = "Le nom doit contenir entre 2 et 50 caractères")
String name,
@NotBlank(message = "L'email est obligatoire")
@Email(message = "Format d'email invalide")
String email,
@NotBlank
@Pattern(regexp = "^(?=.*[A-Z])(?=.*[0-9]).{8,}$",
message = "Le mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre")
String password
) {}Les messages de validation peuvent être externalisés dans des fichiers de messages pour l'internationalisation.
12. Comment gérer les exceptions globalement avec @ControllerAdvice ?
Un gestionnaire d'exceptions centralisé permet de standardiser les réponses d'erreur et d'éviter la duplication de code.
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// Gestion des erreurs de validation
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
FieldError::getDefaultMessage,
(existing, replacement) -> existing
));
return new ErrorResponse("VALIDATION_ERROR", "Données invalides", errors);
}
// Gestion des ressources non trouvées
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
return new ErrorResponse("NOT_FOUND", ex.getMessage(), null);
}
// Gestion des erreurs inattendues
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleUnexpectedError(Exception ex) {
log.error("Erreur inattendue", ex);
return new ErrorResponse("INTERNAL_ERROR", "Une erreur est survenue", null);
}
}public record ErrorResponse(
String code,
String message,
Map<String, String> details,
Instant timestamp
) {
public ErrorResponse(String code, String message, Map<String, String> details) {
this(code, message, details, Instant.now());
}
}Cette approche garantit que toutes les erreurs suivent le même format, facilitant le traitement côté client.
13. Quelle est la différence entre @RequestParam, @PathVariable et @RequestBody ?
Ces trois annotations extraient des données de différentes parties de la requête HTTP.
@RestController
@RequestMapping("/api/products")
public class ProductController {
// @PathVariable : extrait des valeurs de l'URL
// GET /api/products/123
@GetMapping("/{id}")
public Product getProduct(@PathVariable Long id) {
return productService.findById(id);
}
// @RequestParam : extrait des query parameters
// GET /api/products?category=electronics&minPrice=100&page=0
@GetMapping
public Page<Product> searchProducts(
@RequestParam(required = false) String category,
@RequestParam(defaultValue = "0") BigDecimal minPrice,
@RequestParam(defaultValue = "0") int page
) {
return productService.search(category, minPrice, PageRequest.of(page, 20));
}
// @RequestBody : désérialise le corps JSON de la requête
// POST /api/products avec {"name": "...", "price": ...}
@PostMapping
public Product createProduct(@Valid @RequestBody CreateProductRequest request) {
return productService.create(request);
}
// Combinaison des trois dans une même méthode
// PUT /api/products/123?notify=true avec corps JSON
@PutMapping("/{id}")
public Product updateProduct(
@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean notify,
@Valid @RequestBody UpdateProductRequest request
) {
Product updated = productService.update(id, request);
if (notify) {
notificationService.sendUpdateNotification(updated);
}
return updated;
}
}@PathVariable identifie une ressource spécifique, @RequestParam filtre ou pagine les résultats, et @RequestBody transporte les données complexes.
14. Comment implémenter le versioning d'API ?
Plusieurs stratégies existent pour versionner une API REST, chacune avec ses avantages.
// Version par URL (recommandé pour sa clarté)
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public UserV1Response getUser(@PathVariable Long id) {
return userService.findByIdV1(id);
}
}
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public UserV2Response getUser(@PathVariable Long id) {
// V2 inclut des champs supplémentaires
return userService.findByIdV2(id);
}
}// Version par header personnalisé
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public UserV1Response getUserV1(@PathVariable Long id) {
return userService.findByIdV1(id);
}
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public UserV2Response getUserV2(@PathVariable Long id) {
return userService.findByIdV2(id);
}
}// Version par media type (content negotiation)
@RestController
@RequestMapping("/api/users")
public class UserMediaTypeController {
@GetMapping(value = "/{id}", produces = "application/vnd.myapi.v1+json")
public UserV1Response getUserV1(@PathVariable Long id) {
return userService.findByIdV1(id);
}
@GetMapping(value = "/{id}", produces = "application/vnd.myapi.v2+json")
public UserV2Response getUserV2(@PathVariable Long id) {
return userService.findByIdV2(id);
}
}Le versioning par URL reste le plus populaire pour sa simplicité et sa visibilité dans les logs et la documentation.
15. Comment configurer CORS dans Spring Boot ?
CORS (Cross-Origin Resource Sharing) contrôle les requêtes provenant d'origines différentes. Plusieurs niveaux de configuration sont disponibles.
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://frontend.example.com", "https://admin.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("X-Total-Count", "X-Page-Count")
.allowCredentials(true)
.maxAge(3600); // Cache preflight pendant 1 heure
}
}// Configuration par contrôleur ou méthode
@RestController
@RequestMapping("/api/public")
@CrossOrigin(origins = "*", maxAge = 3600)
public class PublicApiController {
@GetMapping("/data")
public DataResponse getPublicData() {
return dataService.getPublicData();
}
// Override au niveau de la méthode
@CrossOrigin(origins = "https://specific-client.com")
@PostMapping("/submit")
public SubmitResponse submitData(@RequestBody SubmitRequest request) {
return dataService.submit(request);
}
}En production, limitez les origines autorisées et utilisez HTTPS. Ne jamais utiliser * avec allowCredentials(true).
Sécurité Spring
16. Comment configurer Spring Security avec JWT ?
Spring Security 6+ utilise une approche fonctionnelle pour la configuration de sécurité.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // Désactivé pour API stateless
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain
) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
String jwt = authHeader.substring(7);
String username = jwtService.extractUsername(jwt);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
chain.doFilter(request, response);
}
}Cette configuration crée une API stateless où chaque requête doit inclure un token JWT valide dans le header Authorization.
17. Comment sécuriser les méthodes avec @PreAuthorize et @PostAuthorize ?
La sécurité au niveau des méthodes offre un contrôle granulaire basé sur les rôles, les permissions ou les données.
@Service
@PreAuthorize("isAuthenticated()") // Toutes les méthodes requièrent l'authentification
public class UserService {
// Seuls les ADMIN peuvent lister tous les utilisateurs
@PreAuthorize("hasRole('ADMIN')")
public List<User> findAll() {
return userRepository.findAll();
}
// L'utilisateur peut voir son profil OU les admins peuvent voir tout profil
@PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
// Vérification après exécution : l'utilisateur ne peut voir que ses propres données
@PostAuthorize("returnObject.owner.id == authentication.principal.id or hasRole('ADMIN')")
public Document getDocument(Long documentId) {
return documentRepository.findById(documentId)
.orElseThrow(() -> new DocumentNotFoundException(documentId));
}
// Filtrage de collection : ne retourne que les éléments autorisés
@PostFilter("filterObject.owner.id == authentication.principal.id")
public List<Project> getUserProjects() {
return projectRepository.findAll();
}
}@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
// Configuration additionnelle si nécessaire
}@PreAuthorize vérifie avant l'exécution, @PostAuthorize après. Les expressions SpEL permettent des règles complexes.
18. Comment protéger les endpoints contre les attaques CSRF ?
CSRF (Cross-Site Request Forgery) exploite la session d'un utilisateur authentifié. La protection est activée par défaut pour les applications avec session.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
// Configuration CSRF pour applications avec session
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
// Exclure certains endpoints de la protection CSRF
.ignoringRequestMatchers("/api/webhooks/**")
)
.build();
}
}@RestController
public class CsrfController {
@GetMapping("/api/csrf")
public CsrfToken csrf(CsrfToken token) {
// Retourne le token CSRF au frontend
return token;
}
}Pour les API REST stateless avec JWT, CSRF peut être désactivé car il n'y a pas de cookie de session à exploiter. La protection est essentielle pour les applications traditionnelles avec session.
Les API JWT sont naturellement protégées contre CSRF car le token doit être explicitement inclus dans chaque requête. Les applications avec cookies de session nécessitent une protection CSRF active.
Tests Spring Boot
19. Quelle est la différence entre @SpringBootTest et @WebMvcTest ?
Ces annotations configurent le contexte de test différemment selon les besoins.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class UserControllerIntegrationTest {
// Charge le contexte complet : tous les beans, base de données, etc.
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldCreateUser() {
CreateUserRequest request = new CreateUserRequest("John", "john@example.com");
ResponseEntity<User> response = restTemplate.postForEntity(
"/api/users", request, User.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().getName()).isEqualTo("John");
}
}@WebMvcTest(UserController.class) // Charge uniquement le layer web
class UserControllerUnitTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean // Remplace @MockBean (déprécié)
private UserService userService;
@Test
void shouldReturnUser() throws Exception {
// Configuration du mock
when(userService.findById(1L))
.thenReturn(Optional.of(new User(1L, "John", "john@example.com")));
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("John"))
.andExpect(jsonPath("$.email").value("john@example.com"));
}
@Test
void shouldReturn404WhenUserNotFound() throws Exception {
when(userService.findById(999L)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/users/999"))
.andExpect(status().isNotFound());
}
}@WebMvcTest est plus rapide car il ne charge que le contrôleur spécifié et ses dépendances directes. @SpringBootTest est nécessaire pour les tests d'intégration complets.
20. Comment tester les repositories avec @DataJpaTest ?
Cette annotation configure un contexte minimal pour tester la couche de persistance avec une base embarquée.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // Utiliser Testcontainers
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindByEmail() {
// Arrange : créer et persister une entité
User user = new User("John", "john@example.com");
entityManager.persistAndFlush(user);
entityManager.clear(); // Vider le cache L1
// Act
Optional<User> found = userRepository.findByEmail("john@example.com");
// Assert
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("John");
}
@Test
void shouldFindActiveUsersWithOrders() {
// Test d'une requête complexe avec jointures
User activeUser = entityManager.persistAndFlush(new User("Active", "active@test.com", true));
User inactiveUser = entityManager.persistAndFlush(new User("Inactive", "inactive@test.com", false));
entityManager.persistAndFlush(new Order(activeUser, BigDecimal.valueOf(100)));
List<User> result = userRepository.findActiveUsersWithOrders();
assertThat(result).hasSize(1);
assertThat(result.get(0).getEmail()).isEqualTo("active@test.com");
}
}TestEntityManager offre des méthodes utilitaires pour les tests JPA, notamment persistAndFlush() qui garantit l'écriture immédiate en base.
21. Comment utiliser Testcontainers pour les tests d'intégration ?
Testcontainers lance des conteneurs Docker pour des tests avec de vraies bases de données ou services.
@SpringBootTest
@Testcontainers
abstract class IntegrationTestBase {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Container
@ServiceConnection
static RedisContainer redis = new RedisContainer("redis:7");
}class OrderServiceIntegrationTest extends IntegrationTestBase {
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@Test
void shouldProcessOrderWithRealDatabase() {
// Créer une commande
Order order = orderService.createOrder(
new CreateOrderRequest(1L, List.of(
new OrderItem(101L, 2),
new OrderItem(102L, 1)
))
);
// Vérifier en base
Order saved = orderRepository.findById(order.getId()).orElseThrow();
assertThat(saved.getItems()).hasSize(2);
assertThat(saved.getStatus()).isEqualTo(OrderStatus.PENDING);
}
@Test
void shouldCacheOrderInRedis() {
Order order = orderService.createOrder(createSampleOrderRequest());
// Premier appel : base de données
Order fetched1 = orderService.findById(order.getId());
// Deuxième appel : cache Redis
Order fetched2 = orderService.findById(order.getId());
assertThat(fetched1).isEqualTo(fetched2);
// Vérifier les métriques de cache si nécessaire
}
}L'annotation @ServiceConnection configure automatiquement les propriétés de connexion, éliminant le besoin de @DynamicPropertySource.
Prêt à réussir tes entretiens Spring Boot ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
Configuration et Monitoring
22. Comment externaliser la configuration avec @ConfigurationProperties ?
Cette annotation permet de mapper des propriétés vers des objets Java typés avec validation.
@ConfigurationProperties(prefix = "app.mail")
@Validated
public record MailProperties(
@NotBlank String host,
@Min(1) @Max(65535) int port,
@NotBlank String username,
@NotBlank String password,
@Valid SenderConfig sender,
@Valid RetryConfig retry
) {
public record SenderConfig(
@NotBlank @Email String address,
@NotBlank String name
) {}
public record RetryConfig(
@Min(1) int maxAttempts,
@Min(100) long delayMs
) {
public RetryConfig {
// Valeurs par défaut
if (maxAttempts == 0) maxAttempts = 3;
if (delayMs == 0) delayMs = 1000;
}
}
}# application.yml
app:
mail:
host: smtp.example.com
port: 587
username: ${MAIL_USERNAME}
password: ${MAIL_PASSWORD}
sender:
address: noreply@example.com
name: MyApp Notifications
retry:
max-attempts: 3
delay-ms: 1000@Configuration
@EnableConfigurationProperties(MailProperties.class)
public class MailConfig {
@Bean
public JavaMailSender mailSender(MailProperties props) {
JavaMailSenderImpl sender = new JavaMailSenderImpl();
sender.setHost(props.host());
sender.setPort(props.port());
sender.setUsername(props.username());
sender.setPassword(props.password());
return sender;
}
}Les records Java (depuis Java 16) sont particulièrement adaptés aux @ConfigurationProperties car ils sont immuables et concis.
23. Comment utiliser les profils Spring pour différents environnements ?
Les profils permettent d'adapter la configuration selon l'environnement d'exécution.
# application.yml - Configuration par défaut
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
datasource:
url: ${DATABASE_URL:jdbc:h2:mem:testdb}
---
# application-dev.yml
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:postgresql://localhost:5432/myapp_dev
jpa:
show-sql: true
hibernate:
ddl-auto: update
logging:
level:
com.example: DEBUG
---
# application-prod.yml
spring:
config:
activate:
on-profile: prod
datasource:
url: ${DATABASE_URL}
hikari:
maximum-pool-size: 20
jpa:
show-sql: false
hibernate:
ddl-auto: validate
logging:
level:
com.example: INFO@Component
@Profile("dev") // Uniquement actif en développement
public class DevDataInitializer implements CommandLineRunner {
private final UserRepository userRepository;
@Override
public void run(String... args) {
// Créer des données de test
userRepository.save(new User("dev@example.com", "Dev User", "password"));
userRepository.save(new User("test@example.com", "Test User", "password"));
}
}// Service avec comportement conditionnel
@Service
public class NotificationService {
@Value("${app.notifications.enabled:true}")
private boolean notificationsEnabled;
@Autowired(required = false) // Optionnel selon le profil
private EmailService emailService;
public void sendNotification(String message) {
if (notificationsEnabled && emailService != null) {
emailService.send(message);
} else {
log.info("Notification (disabled): {}", message);
}
}
}Les profils peuvent être combinés : spring.profiles.active=prod,metrics active les deux profils simultanément.
24. Comment exposer des métriques personnalisées avec Actuator et Micrometer ?
Micrometer fournit une façade pour les systèmes de monitoring. Les métriques personnalisées enrichissent l'observabilité.
@Component
public class OrderMetrics {
private final Counter ordersCreated;
private final Counter ordersFailed;
private final Timer orderProcessingTime;
private final AtomicInteger activeOrders;
public OrderMetrics(MeterRegistry registry) {
// Compteur pour les commandes créées par statut
this.ordersCreated = Counter.builder("orders.created")
.description("Nombre de commandes créées")
.tag("application", "order-service")
.register(registry);
this.ordersFailed = Counter.builder("orders.failed")
.description("Nombre de commandes échouées")
.register(registry);
// Timer pour mesurer le temps de traitement
this.orderProcessingTime = Timer.builder("orders.processing.time")
.description("Temps de traitement des commandes")
.publishPercentiles(0.5, 0.95, 0.99)
.register(registry);
// Gauge pour le nombre de commandes actives
this.activeOrders = new AtomicInteger(0);
Gauge.builder("orders.active", activeOrders, AtomicInteger::get)
.description("Nombre de commandes en cours de traitement")
.register(registry);
}
public void recordOrderCreated() {
ordersCreated.increment();
}
public void recordOrderFailed() {
ordersFailed.increment();
}
public Timer.Sample startProcessing() {
activeOrders.incrementAndGet();
return Timer.start();
}
public void stopProcessing(Timer.Sample sample) {
sample.stop(orderProcessingTime);
activeOrders.decrementAndGet();
}
}@Service
public class OrderService {
private final OrderMetrics metrics;
public Order processOrder(CreateOrderRequest request) {
Timer.Sample sample = metrics.startProcessing();
try {
Order order = createOrder(request);
metrics.recordOrderCreated();
return order;
} catch (Exception e) {
metrics.recordOrderFailed();
throw e;
} finally {
metrics.stopProcessing(sample);
}
}
}Ces métriques sont exposées via /actuator/prometheus pour Prometheus ou /actuator/metrics pour l'API JSON.
25. Comment créer un HealthIndicator personnalisé ?
Les indicateurs de santé permettent de monitorer l'état de composants critiques.
@Component
public class ExternalApiHealthIndicator implements HealthIndicator {
private final RestClient restClient;
private final ExternalApiProperties properties;
@Override
public Health health() {
try {
long startTime = System.currentTimeMillis();
ResponseEntity<Void> response = restClient.get()
.uri(properties.getHealthEndpoint())
.retrieve()
.toBodilessEntity();
long responseTime = System.currentTimeMillis() - startTime;
if (response.getStatusCode().is2xxSuccessful()) {
return Health.up()
.withDetail("responseTime", responseTime + "ms")
.withDetail("endpoint", properties.getHealthEndpoint())
.build();
} else {
return Health.down()
.withDetail("status", response.getStatusCode().value())
.build();
}
} catch (Exception e) {
return Health.down()
.withException(e)
.withDetail("endpoint", properties.getHealthEndpoint())
.build();
}
}
}@Component
public class DatabaseHealthContributor implements CompositeHealthContributor {
private final Map<String, HealthIndicator> indicators;
public DatabaseHealthContributor(
DataSource primaryDataSource,
@Qualifier("replicaDataSource") DataSource replicaDataSource
) {
this.indicators = Map.of(
"primary", new DataSourceHealthIndicator(primaryDataSource),
"replica", new DataSourceHealthIndicator(replicaDataSource)
);
}
@Override
public HealthContributor getContributor(String name) {
return indicators.get(name);
}
@Override
public Iterator<NamedContributor<HealthContributor>> iterator() {
return indicators.entrySet().stream()
.map(e -> NamedContributor.of(e.getKey(), e.getValue()))
.iterator();
}
}Le endpoint /actuator/health agrège tous les indicateurs. La configuration management.endpoint.health.show-details=always expose les détails.
Concepts Avancés
26. Comment implémenter un cache avec Spring Cache et Redis ?
L'abstraction Spring Cache simplifie l'intégration de caches distribués comme Redis.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
// Configurations spécifiques par cache
Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
"users", config.entryTtl(Duration.ofHours(1)),
"products", config.entryTtl(Duration.ofMinutes(30)),
"sessions", config.entryTtl(Duration.ofMinutes(5))
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}@Service
public class ProductService {
// Mise en cache automatique du résultat
@Cacheable(value = "products", key = "#id")
public Product findById(Long id) {
log.info("Fetching product {} from database", id);
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
// Mise à jour du cache lors de la modification
@CachePut(value = "products", key = "#product.id")
public Product update(Product product) {
return productRepository.save(product);
}
// Invalidation du cache
@CacheEvict(value = "products", key = "#id")
public void delete(Long id) {
productRepository.deleteById(id);
}
// Invalidation de tout le cache
@CacheEvict(value = "products", allEntries = true)
public void clearProductCache() {
log.info("Product cache cleared");
}
// Clé composite pour les recherches
@Cacheable(value = "productSearch", key = "#category + '-' + #minPrice + '-' + #maxPrice")
public List<Product> search(String category, BigDecimal minPrice, BigDecimal maxPrice) {
return productRepository.findByCategoryAndPriceBetween(category, minPrice, maxPrice);
}
}Les annotations de cache sont transparentes : le code métier reste inchangé et le caching est déclaratif.
27. Comment implémenter une tâche planifiée avec @Scheduled ?
Spring propose plusieurs façons de planifier des tâches récurrentes.
@Configuration
@EnableScheduling
public class SchedulerConfig {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("scheduled-");
scheduler.setErrorHandler(t -> log.error("Scheduled task error", t));
return scheduler;
}
}@Component
public class ScheduledTasks {
private static final Logger log = LoggerFactory.getLogger(ScheduledTasks.class);
// Exécution toutes les 5 minutes
@Scheduled(fixedRate = 5 * 60 * 1000)
public void syncExternalData() {
log.info("Starting external data sync");
// Synchronisation avec un système externe
}
// Exécution 10 secondes après la fin de la précédente
@Scheduled(fixedDelay = 10000, initialDelay = 5000)
public void processQueue() {
// Traitement de queue avec délai entre exécutions
}
// Expression cron : tous les jours à 2h du matin
@Scheduled(cron = "0 0 2 * * *", zone = "Europe/Paris")
public void dailyCleanup() {
log.info("Running daily cleanup");
cleanupService.removeExpiredSessions();
cleanupService.archiveOldData();
}
// Cron configurable via propriétés
@Scheduled(cron = "${app.reports.cron:0 0 6 * * MON}")
public void generateWeeklyReport() {
reportService.generateAndSend();
}
}@Component
@ConditionalOnProperty(name = "app.scheduling.enabled", havingValue = "true")
public class ConditionalScheduledTasks {
@Scheduled(fixedRate = 60000)
public void monitorSystem() {
// Monitoring actif uniquement si configuré
}
}Les tâches planifiées s'exécutent sur un thread pool séparé pour ne pas bloquer le traitement des requêtes.
28. Comment gérer les événements avec ApplicationEvent ?
Le système d'événements permet un découplage entre les composants de l'application.
public class UserRegisteredEvent extends ApplicationEvent {
private final User user;
private final Instant registeredAt;
public UserRegisteredEvent(Object source, User user) {
super(source);
this.user = user;
this.registeredAt = Instant.now();
}
public User getUser() { return user; }
public Instant getRegisteredAt() { return registeredAt; }
}@Service
public class UserService {
private final ApplicationEventPublisher eventPublisher;
@Transactional
public User registerUser(CreateUserRequest request) {
User user = new User(request.email(), request.name());
user = userRepository.save(user);
// Publication de l'événement après la transaction
eventPublisher.publishEvent(new UserRegisteredEvent(this, user));
return user;
}
}@Component
public class UserEventListeners {
private static final Logger log = LoggerFactory.getLogger(UserEventListeners.class);
// Listener synchrone
@EventListener
public void handleUserRegistered(UserRegisteredEvent event) {
log.info("User registered: {}", event.getUser().getEmail());
}
// Listener asynchrone pour ne pas bloquer le thread principal
@Async
@EventListener
public void sendWelcomeEmail(UserRegisteredEvent event) {
emailService.sendWelcomeEmail(event.getUser());
}
// Listener transactionnel : s'exécute après le commit
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void notifyExternalSystem(UserRegisteredEvent event) {
// Notification externe uniquement si la transaction réussit
externalApiClient.notifyNewUser(event.getUser().getId());
}
// Listener conditionnel avec SpEL
@EventListener(condition = "#event.user.role == 'PREMIUM'")
public void handlePremiumUserRegistered(UserRegisteredEvent event) {
premiumService.initializePremiumFeatures(event.getUser());
}
}Les événements transactionnels (@TransactionalEventListener) garantissent la cohérence entre la base de données et les effets de bord.
29. Comment implémenter un client HTTP avec RestClient (Spring Boot 3+) ?
RestClient est l'API moderne et fluide pour les appels HTTP synchrones, remplaçant RestTemplate.
@Component
public class ExternalApiClient {
private final RestClient restClient;
public ExternalApiClient(RestClient.Builder builder, ExternalApiProperties props) {
this.restClient = builder
.baseUrl(props.getBaseUrl())
.defaultHeader("Authorization", "Bearer " + props.getApiKey())
.defaultHeader("Accept", "application/json")
.requestInterceptor((request, body, execution) -> {
log.debug("Request: {} {}", request.getMethod(), request.getURI());
return execution.execute(request, body);
})
.build();
}
public User fetchUser(Long id) {
return restClient.get()
.uri("/users/{id}", id)
.retrieve()
.body(User.class);
}
public List<Product> searchProducts(String query, int page) {
return restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/products/search")
.queryParam("q", query)
.queryParam("page", page)
.build())
.retrieve()
.body(new ParameterizedTypeReference<List<Product>>() {});
}
public Order createOrder(CreateOrderRequest request) {
return restClient.post()
.uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {
throw new OrderValidationException("Invalid order: " + res.getStatusCode());
})
.body(Order.class);
}
// Gestion des erreurs avec ResponseEntity
public Optional<User> findUser(Long id) {
ResponseEntity<User> response = restClient.get()
.uri("/users/{id}", id)
.retrieve()
.toEntity(User.class);
return response.getStatusCode().is2xxSuccessful()
? Optional.ofNullable(response.getBody())
: Optional.empty();
}
}RestClient offre une API similaire à WebClient mais pour les appels bloquants, idéale pour les applications qui n'utilisent pas la programmation réactive.
30. Comment gérer les migrations de base de données avec Flyway ?
Flyway automatise les migrations de schéma de manière versionnée et reproductible.
# application.properties
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true
spring.flyway.validate-on-migrate=true-- db/migration/V1__create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);-- db/migration/V2__add_user_status.sql
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'ACTIVE';
CREATE INDEX idx_users_status ON users(status);-- db/migration/V3__create_orders_table.sql
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
total_amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);@Configuration
public class FlywayConfig {
@Bean
public FlywayMigrationStrategy migrationStrategy() {
return flyway -> {
// Validation avant migration
flyway.validate();
// Exécution des migrations
flyway.migrate();
};
}
// Callback pour actions post-migration
@Bean
public Callback flywayCallback() {
return new BaseCallback() {
@Override
public void handle(Event event, Context context) {
if (event == Event.AFTER_MIGRATE) {
log.info("Migrations completed successfully");
}
}
};
}
}Chaque fichier de migration est exécuté une seule fois et son checksum est vérifié pour détecter les modifications accidentelles.
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Conclusion
Ces 30 questions couvrent les aspects essentiels de Spring Boot évalués en entretien technique. La maîtrise de ces concepts démontre une compréhension approfondie du framework et des bonnes pratiques de développement Java.
Checklist de préparation :
- ✅ Comprendre le mécanisme d'auto-configuration et les conditions
- ✅ Maîtriser Spring Data JPA : requêtes, transactions, problème N+1
- ✅ Savoir configurer Spring Security avec JWT et la sécurité des méthodes
- ✅ Connaître les différentes annotations de test et leurs cas d'usage
- ✅ Utiliser les profils et @ConfigurationProperties pour la configuration
- ✅ Implémenter le caching, le scheduling et les événements
- ✅ Exposer des métriques et des indicateurs de santé personnalisés
- ✅ Maîtriser RestClient pour les appels HTTP modernes
La pratique régulière avec des projets concrets reste le meilleur moyen de consolider ces connaissances et de répondre avec assurance aux questions techniques.
Tags
Partager
Articles similaires

Spring Boot 3.4 : Toutes les nouveautés et améliorations
Découvrez les nouveautés de Spring Boot 3.4 : structured logging, virtual threads, graceful shutdown par défaut, RestClient amélioré et MockMvcTester.

Spring Modulith : architecture modulaire monolithique expliquée
Découvrez Spring Modulith pour construire des monolithes modulaires en Java. Architecture, modules, événements asynchrones et tests avec exemples Spring Boot 3.

Spring Batch 5 en entretien technique : partitioning, chunks et fault tolerance
Préparez vos entretiens Spring Batch 5 : 15 questions essentielles sur le partitioning, chunk-oriented processing, fault tolerance avec exemples de code Java 21.