Développement Java & APIs : Spring Boot, REST et intégration SI en pratique
Ce que recouvre une mission de développement Java API REST avec Spring Boot : architecture, gestion des erreurs, sécurité JWT, intégration SI, tests et déploiement. Guide concret pour DSI et chefs de projet.
Le développement d’une API Java avec Spring Boot est souvent présenté comme simple. Et il l’est — pour la partie “ça marche en local”. Ce qui prend du temps dans un vrai projet : la sécurité, la gestion des erreurs, l’intégration avec les systèmes existants, les tests, et le déploiement. Voici ce que ça implique concrètement.
Ce que Spring Boot apporte réellement
Spring Boot n’est pas juste un framework — c’est un point de vue sur comment structurer une application Java. Il prend des décisions par défaut (embedded Tomcat, configuration auto, Starter Dependencies) qui accélèrent le démarrage mais peuvent surprendre si on ne connaît pas les conventions.
Configuration d’un projet Spring Boot API :
<!-- pom.xml — structure de base -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
Architecture en couches : pourquoi c’est important
Une API Java bien structurée en couches évite la “big ball of mud” :
Controller (HTTP) → Service (métier) → Repository (données)
↕ ↕ ↕
DTOs Domain Models Entities JPA
Controller — uniquement du HTTP :
@RestController
@RequestMapping("/api/v1/commandes")
@RequiredArgsConstructor
public class CommandeController {
private final CommandeService commandeService;
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public CommandeResponse creer(@Valid @RequestBody CommandeRequest request,
@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
CommandeDto commande = commandeService.creer(request, userId);
return CommandeResponse.from(commande);
}
@GetMapping("/{id}")
public CommandeResponse findById(@PathVariable UUID id,
@AuthenticationPrincipal Jwt jwt) {
return CommandeResponse.from(commandeService.findById(id, jwt.getSubject()));
}
}
Service — logique métier isolée :
@Service
@Transactional
@RequiredArgsConstructor
public class CommandeServiceImpl implements CommandeService {
private final CommandeRepository commandeRepository;
private final ProduitRepository produitRepository;
private final PrixService prixService;
private final CommandeMapper mapper;
private final ApplicationEventPublisher eventPublisher;
@Override
public CommandeDto creer(CommandeRequest request, String userId) {
// Validation métier (au-delà de la validation syntaxique du Controller)
validerDisponibiliteProduits(request.lignes());
Commande commande = new Commande();
commande.setUserId(userId);
commande.setStatut(StatutCommande.EN_ATTENTE);
List<LigneCommande> lignes = request.lignes().stream()
.map(l -> creerLigne(l, commande))
.toList();
commande.setLignes(lignes);
commande.calculerTotal();
Commande savedCommande = commandeRepository.save(commande);
// Event pour les side-effects (email, stock, analytics)
eventPublisher.publishEvent(new CommandeCreeeEvent(savedCommande.getId()));
return mapper.toDto(savedCommande);
}
}
Sécurité : JWT avec Spring Security
La sécurité est souvent ajoutée en fin de projet — erreur classique. Elle doit être conçue dès le début.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/produits/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter()))
)
.build();
}
@Bean
public JwtAuthenticationConverter jwtConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
authoritiesConverter.setAuthoritiesClaimName("roles");
authoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
return converter;
}
}
Intégration SI : patterns récurrents
L’intégration avec un SI existant (ERP, CRM, PIM) représente souvent 40-60% du temps sur un projet d’API. Trois patterns reviennent systématiquement :
Pattern 1 — Appel HTTP synchrone avec résilience :
@Service
@RequiredArgsConstructor
public class ErpClient {
private final RestTemplate restTemplate;
// Circuit breaker avec Resilience4j
@CircuitBreaker(name = "erp", fallbackMethod = "getPrixFallback")
@Retry(name = "erp")
@TimeLimiter(name = "erp")
public PrixDto getPrix(String sku) {
return restTemplate.getForObject(
"{url}/prix/{sku}",
PrixDto.class,
erpBaseUrl, sku
);
}
private PrixDto getPrixFallback(String sku, Exception ex) {
log.warn("ERP indisponible pour SKU {}, utilisation du cache", sku, ex);
return prixCache.getOrDefault(sku, PrixDto.prixDefaut());
}
}
Pattern 2 — File de messages pour les événements critiques :
// Quand l'API distante peut être indisponible
@Component
@RequiredArgsConstructor
public class CommandeEventListener {
private final JmsTemplate jmsTemplate;
@EventListener
@Async
public void onCommandeCreee(CommandeCreeeEvent event) {
CommandeMessage message = new CommandeMessage(event.getCommandeId());
jmsTemplate.convertAndSend("commandes.nouvelles", message);
// Le système downstream consomme quand il est disponible
}
}
Pattern 3 — Transformation et mapping de données hétérogènes :
@Component
public class ErpProduitMapper {
// L'ERP a son propre modèle de données — on mappe vers notre domaine
public Produit fromErpResponse(ErpProduitDto erp) {
return Produit.builder()
.sku(erp.getCodeProduit())
.nom(erp.getLibelle() != null ? erp.getLibelle() : erp.getReference())
.prixHT(parsePrix(erp.getPrixVente()))
.categorieId(mapCategorie(erp.getFamille(), erp.getSousFamille()))
.actif(!"I".equals(erp.getStatut())) // Statut ERP : A=Actif, I=Inactif
.build();
}
private BigDecimal parsePrix(String prixString) {
// L'ERP envoie le prix en centimes en string...
return new BigDecimal(prixString).divide(BigDecimal.valueOf(100), 2, HALF_UP);
}
}
Tests : la partie qu’on bâcle à tort
Tests unitaires des services (Mockito) :
@ExtendWith(MockitoExtension.class)
class CommandeServiceTest {
@Mock CommandeRepository commandeRepository;
@Mock PrixService prixService;
@InjectMocks CommandeServiceImpl commandeService;
@Test
void creerCommande_produitIndisponible_leveException() {
// Given
CommandeRequest request = CommandeRequest.builder()
.lignes(List.of(new LigneRequest("SKU-001", 5)))
.build();
when(prixService.isDisponible("SKU-001", 5)).thenReturn(false);
// When / Then
assertThatThrownBy(() -> commandeService.creer(request, "user-123"))
.isInstanceOf(ProduitIndisponibleException.class)
.hasMessageContaining("SKU-001");
verify(commandeRepository, never()).save(any());
}
}
Tests d’intégration sur les endpoints (Testcontainers) :
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class CommandeControllerIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb");
@DynamicPropertySource
static void postgresProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
}
@Test
void creerCommande_retourne201AvecLocation() {
// Test avec une vraie base de données PostgreSQL
// (pas un mock H2 qui ne se comporte pas pareil)
}
}
Estimation d’un projet API Java
À titre indicatif, les volumes que je rencontre :
| Type de besoin | Estimation |
|---|---|
| API CRUD simple (1 entité, JWT inclus) | 3-5 jours |
| API avec 5-8 endpoints + intégration 1 SI | 10-15 jours |
| Microservice avec messaging (Kafka/JMS) | 8-12 jours |
| Refactoring / reprise d’une API existante | Audit (2j) + selon findings |
| API complète (10+ endpoints, 2+ intégrations SI, tests > 70%) | 20-30 jours |
Ces estimations varient selon la complexité métier, la documentation des APIs tierces, et la disponibilité des environnements de test.
Si vous avez un besoin d’API Java REST — nouveau développement, intégration SI, optimisation ou reprise de code existant — décrivez votre contexte pour qu’on cadre ensemble l’intervention.
Amine MEGDICHE
Développeur AEM & Java Full Stack — Freelance depuis 2013