java-dev-plugin
Développement Java/Spring Boot avec architecture DDD hexagonale (Spring Boot 4, Java 25)
Components
bolt
java-backend
Skill
Socle Commun Java/Spring Boot
Tu es un expert Java/Spring Boot. Applique strictement les préconisations suivantes pour tout développements Springboot.
Ressources complémentaires — Couches entrantes :
- API Web (WebMVC) : Voir
references/application-web.mdpour les spécificités REST entrant - Worker (RabbitMQ) : Voir
references/application-rabbit.mdpour les spécificités messaging entrant
Ressources complémentaires — Couches sortantes :
- Appels HTTP sortants : Voir
references/infrastructure-http.mdpour RestClient, TokenProvider, adaptateurs HTTP - Publication RabbitMQ : Voir
references/infrastructure-rabbit.mdpour RabbitTemplate, publication de messages - Persistance JPA PostgreSQL : Voir
references/infrastructure-jpa-postgre.mdpour JPA/Spring Data, Flyway, Testcontainers avec PostgreSQL
Stack Technique
| Élément | Version/Valeur |
|---|---|
| Java | 25 |
| Spring Boot | 4.0.6 |
| Build | Maven |
| Starters communs | starter-actuator, starter-validation, starter-restclient, starter-test |
Architecture DDD (Hexagonale)
application/ # Adaptateurs entrants (voir cas spécifiques)
domain/ # Logique métier pure
├── model/ # Modèles métiers : classes, value classes, enum
├── service/ # Services métier (@Service autorisé)
├── repository/ # Interfaces (ports sortants)
└── exception/ # RuntimeException uniquement
infrastructure/ # Adaptateurs sortants
└── {api}/ # Ex: directus/, salesforce/, adlinks/
├── client/ # RestClient + TokenProvider
├── repository/ # Implémentations des ports
└── mapper/ # DTO ↔ Domain
config/ # @Configuration globales
Principes clés :
- Le domain ne doit JAMAIS dépendre de l'infrastructure ou du server
- Les repository interfaces sont définies dans le domain, implémentées dans l'infrastructure
- Communication entre couches via des ports (interfaces)
- Les mappers sont des classes utilitaires immutables :
final class, constructeurprivate, toutes les méthodesstatic— pas de@Component, pas d'état
Configuration Spring
Deux fichiers principaux :
src/main/resources/
├── application.yml # Configuration par défaut (dev local)
└── application-docker.yml # Surcharges pour le déploiement Docker
❌ Interdit : Sections
---avecon-profile:dans un même fichier
Virtual Threads (obligatoire)
spring:
threads:
virtual:
enabled: true
Construction d'image Docker
Demander à l'utilisateur quelle approche il souhaite avant de générer quoi que ce soit :
« Pour la construction de l'image Docker, deux approches sont possibles :
- Buildpacks (
spring-boot:build-image) — aucun Dockerfile à maintenir, image optimisée automatiquement- Dockerfile — contrôle total, AOT + layering explicites, recommandé si la CI impose un Dockerfile ou si des optimisations de démarrage sont critiques
Laquelle préférez-vous ? »
Option 1 — Buildpacks
./mvnw spring-boot:build-image
La CI se branche directement sur cette commande Maven. Aucun Dockerfile n'est créé.
Option 2 — Dockerfile (AOT + Layering)
Utilise l'image bellsoft/liberica-openjre-debian:25.0.2-cds, le layering Spring Boot et le cache AOT pour des démarrages optimisés.
# Perform the extraction in a separate builder container
FROM docker.io/bellsoft/liberica-openjre-debian:25.0.2-cds AS builder
WORKDIR /builder
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted
# Runtime container
FROM docker.io/bellsoft/liberica-openjre-debian:25.0.2-cds
WORKDIR /application
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./
# AOT cache training
RUN java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -Dspring.context.exit=onRefresh -Dspring.profiles.active=aot-build -jar application.jar
RUN java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -jar application.jar && rm app.aotconf
ENTRYPOINT exec java $JAVA_OPTS -Dspring.profiles.active=docker -XX:AOTCache=app.aot -jar application.jar
La CI se branche sur ce Dockerfile pour construire l'image.
Prometheus — Compteurs d'erreurs API (obligatoire)
Tout appel API externe en erreur doit incrémenter un compteur Prometheus.
Nommage : <app>_<api>_errors_total
@Component
public class AdFabConformiteAdapter implements ConformiteVisuelPort {
private final AdFabClient client;
private final Counter errorsCounter;
public AdFabConformiteAdapter(AdFabClient client, MeterRegistry meterRegistry) {
this.client = client;
this.errorsCounter = Counter.builder("fabrication_worker_adfab_errors_total")
.description("Nombre d'erreurs lors des appels à l'API AdFab")
.register(meterRegistry);
}
@Override
public void updateConformite(ConformiteVisuelNormalisee conformite) {
try {
client.updateStatut(conformite);
} catch (Exception e) {
errorsCounter.increment(); // ✅ .increment() pas .inc()
throw e; // Relancer pour ne pas avaler l'erreur
}
}
}
❌ Interdit : appel API sans compteur d'erreur dans un adaptateur infrastructure ❌ Interdit : avaler l'exception après le
.increment()sans la relancer
Standards de Code
Variables locales : final var obligatoire
// ✅ Correct
final var commande = new Commande(...);
final var result = service.process(data);
// ❌ Interdit
Commande commande = new Commande(...); // Type explicite
var result = service.process(data); // final manquant
Exceptions (types explicites autorisés) : champs de classe, paramètres, types de retour
Principes généraux
| Principe | Détail |
|---|---|
| Immutabilité | records, List.of, Map.of |
| Programmation fonctionnelle | Stream API, Optional, lambdas |
| Pas de boucles | Utiliser Stream au lieu de for/while |
| Méthodes courtes | Max 20 lignes |
| Commentaires | Uniquement si logique complexe |
Exceptions métier
- Unchecked uniquement (extends RuntimeException)
- Gestion centralisée (@ControllerAdvice, @RabbitListenerErrorHandler)
- Pas de try-catch dans le code métier
Optional - API fluent obligatoire
// ✅ Correct
final var commande = repository.findById(id)
.orElseThrow(() -> new CommandeNotFoundException(id));
// ❌ Interdit
optional.get(); // Jamais
optional.isPresent() + get() // Anti-pattern
Logging SLF4J
private static final Logger logger = LoggerFactory.getLogger(CommandeService.class);
logger.info("Processing commande {}", commandeId); // ✅ Paramétré
logger.info("Processing " + commandeId); // ❌ Concaténation
Jackson 3 (obligatoire)
Spring Boot 4 utilise Jackson 3 par défaut :
// ✅ Correct - Jackson 3
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.JsonNode;
// ❌ Interdit - Jackson 2 (ancien package)
import com.fasterxml.jackson.databind.ObjectMapper;
La seule exception concerne le package 'com.fasterxml.jackson.annotation' qui lui n'a pas encore migré.
Structure server/scheduler/ (commun)
server/
└── scheduler/ # @Scheduled, CommandLineRunner
Tests - Principes Généraux
| Convention | Détail |
|---|---|
| Nommage | shouldXxxWhenYyy() ou givenXxx_whenYyy_thenZzz() |
| @DisplayName | Pas utilisé - nom de méthode explicite |
| Assertions | AssertJ pour toutes les assertions |
| Structure | Arrange-Act-Assert |
Assertions AssertJ — Optional
// ✅ Valeur exacte connue
assertThat(result).hasValue(new Operation("OP-42", "CRM-99"));
// ✅ Présence + vérification partielle
assertThat(result).hasValueSatisfying(op -> assertThat(op.id()).isEqualTo("OP-42"));
// ✅ Présence seule
assertThat(result).isPresent();
// ✅ Absence
assertThat(result).isEmpty();
// ❌ Interdit — optional.get() dans les tests comme en prod
assertThat(result.get().id()).isEqualTo("OP-42");
// ❌ Interdit — isNull() / isNotNull() sur un Optional
assertThat(result).isNotNull();
Couverture cible
| Couche | Couverture minimale |
|---|---|
| Domain | ≥ 90% |
| Infrastructure | ≥ 80% |
Outils par couche
| Couche | Outil | Approche |
|---|---|---|
| Domain | JUnit 5 pur | Fakes (pas Mockito), injection constructeur |
| Infrastructure - API | MockWebServer | Vérifier appels HTTP, désérialisation |
| Infrastructure - DB | Testcontainers | PostgreSQL, MongoDB, etc. |
Tests d'architecture (ArchUnit)
@AnalyzeClasses(packages = "com.example.app")
public class ArchitectureTest {
@ArchTest
static final ArchRule domainShouldNotDependOnAdapters =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("..infrastructure..", "..server..");
@ArchTest
static final ArchRule repositoriesShouldBeInterfaces =
classes().that().resideInAPackage("..domain.repository..")
.should().beInterfaces();
@ArchTest
static final ArchRule exceptionsShouldExtendRuntimeException =
classes().that().resideInAPackage("..domain.exception..")
.should().beAssignableTo(RuntimeException.class);
}
description Ressources référencées
references/application-web.md
API Web (WebMVC)
Applique ces spécificités uniquement si le projet est identifié comme une API Web (présence de
spring-boot-starter-web, controllers REST, endpoints HTTP).
Dépendances spécifiques
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</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-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>3.0.3</version>
</dependency>
<!-- Tests controllers -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
Structure application/
application/
└── web/
├── controller/ # @RestController
├── dto/ # Request/Response DTOs
├── mapper/ # DTO ↔ Domain
├── config/ # Security, CORS, etc.
└── exception/ # @ControllerAdvice
Conventions REST
| Aspect | Convention |
|---|---|
| Nommage endpoints | kebab-case, pluriel (/api/v1/commandes) |
| GET | Lecture |
| POST | Création |
| PUT | Remplacement complet |
| PATCH | Modification partielle |
| DELETE | Suppression |
| Pagination | ?page=0&size=20&sort=createdAt,desc |
Codes retour
| Code | Signification |
|---|---|
| 200 | OK |
| 201 | Created |
| 204 | No Content |
| 400 | Bad Request |
| 404 | Not Found |
| 500 | Internal Server Error |
Controllers - Retour direct avec @ResponseStatus
// ✅ Correct - Retour direct avec @ResponseStatus
@GetMapping("/{id}")
public CommandeResponse getById(@PathVariable String id) {
return commandeService.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public CommandeResponse create(@Valid @RequestBody CreateCommandeRequest request) {
return commandeService.create(request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable String id) {
commandeService.delete(id);
}
// ❌ Éviter - ResponseEntity verbose
@GetMapping("/{id}")
public ResponseEntity<CommandeResponse> getById(@PathVariable String id) {
return ResponseEntity.ok(commandeService.findById(id));
}
Gestion d'erreurs centralisée avec @ResponseStatus
@ResponseStatus(HttpStatus.NOT_FOUND)
public class CommandeNotFoundException extends RuntimeException {
public CommandeNotFoundException(String id) {
super("Commande not found: " + id);
}
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class InvalidCommandeException extends RuntimeException {
public InvalidCommandeException(String message) {
super(message);
}
}
@ControllerAdvice pour cas spécifiques
Utiliser @ControllerAdvice uniquement pour les cas nécessitant une réponse personnalisée :
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
final var errors = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.toList();
return new ErrorResponse("VALIDATION_ERROR", String.join(", ", errors));
}
}
public record ErrorResponse(String code, String message) {}
Documentation API avec SpringDoc OpenAPI
Configuration application.yml
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
operationsSorter: method
Annotations sur les Controllers
@RestController
@RequestMapping("/api/v1/commandes")
@Tag(name = "Commandes", description = "Gestion des commandes")
public class CommandeController {
private final CommandeService commandeService;
public CommandeController(CommandeService commandeService) {
this.commandeService = commandeService;
}
@Operation(summary = "Récupérer une commande", description = "Retourne une commande par son ID")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Commande trouvée"),
@ApiResponse(responseCode = "404", description = "Commande non trouvée")
})
@GetMapping("/{id}")
public CommandeResponse getById(@PathVariable String id) {
return commandeService.findById(id);
}
@Operation(summary = "Créer une commande")
@ApiResponse(responseCode = "201", description = "Commande créée")
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public CommandeResponse create(@Valid @RequestBody CreateCommandeRequest request) {
return commandeService.create(request);
}
}
Annotations sur les DTOs
@Schema(description = "Requête de création de commande")
public record CreateCommandeRequest(
@Schema(description = "Référence produit", example = "PROD-123")
@NotBlank String productRef,
@Schema(description = "Quantité", example = "5", minimum = "1")
@Min(1) int quantity
) {}
Tests spécifiques API (Slice test)
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@WebMvcTest(CommandeController.class)
class CommandeControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private CommandeService commandeService;
@Test
void shouldReturnCommandeWhenExists() throws Exception {
final var commande = new CommandeResponse("CMD-123", "PROD-001", 5);
when(commandeService.findById("CMD-123")).thenReturn(commande);
mockMvc.perform(get("/api/v1/commandes/CMD-123"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("CMD-123"));
}
@Test
void shouldReturn404WhenCommandeNotFound() throws Exception {
when(commandeService.findById("CMD-999"))
.thenThrow(new CommandeNotFoundException("CMD-999"));
mockMvc.perform(get("/api/v1/commandes/CMD-999"))
.andExpect(status().isNotFound());
}
}
references/application-rabbit.md
Worker (RabbitMQ)
Applique ces spécificités uniquement si le projet est identifié comme un Worker (présence de
spring-boot-starter-amqp, listeners RabbitMQ, traitement de messages).
Dépendances spécifiques
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
Structure application/
application/
└── amqp/
├── listener/ # @RabbitListener (crée queues/bindings)
├── config/ # Connexion RabbitMQ uniquement
└── model/ # DTOs messages
RabbitMQ - Création automatique des queues
Les @RabbitListener sont responsables de :
- Déclarer les queues et bindings automatiquement
- Écouter les messages entrants
- Déléguer le traitement aux services du domain
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "commandes.queue", durable = "true"),
exchange = @Exchange(name = "commandes.exchange", type = ExchangeTypes.TOPIC),
key = "commande.created"
))
public void onCommandeCreated(CommandeMessage message) {
// Traitement du message
}
Gestion d'erreurs
Utiliser @RabbitListenerErrorHandler pour la gestion centralisée des erreurs :
@Configuration
public class RabbitErrorConfig {
@Bean
public RabbitListenerErrorHandler globalErrorHandler() {
return (message, channel, throwable) -> {
// Log et gestion de l'erreur
throw new AmqpRejectAndDontRequeueException(throwable);
};
}
}
Tests spécifiques Worker
@Testcontainers
class CommandeListenerE2ETest {
@Container
static RabbitMQContainer rabbitMQ = new RabbitMQContainer("rabbitmq:3.12");
@Test
void shouldProcessCommandeWorkflow() {
// Publier message RabbitMQ
rabbitTemplate.convertAndSend("commandes.exchange", "commande.creer", message);
// Attendre et vérifier
await().atMost(5, SECONDS).untilAsserted(() -> {
final var commande = repository.findByClientId(clientId);
assertThat(commande).isNotNull();
});
}
}
references/infrastructure-http.md
Infrastructure — Appels HTTP Sortants (RestClient)
Applique ces spécificités uniquement si le projet émet des appels HTTP vers des APIs externes (présence d'adaptateurs dans
infrastructure.http.*appelant des services tiers via RestClient).
Dépendances spécifiques
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
Structure infrastructure/
infrastructure/
└── http/
└── {api}/ # Ex: commande/, directus/, salesforce/
├── {Api}{Port}Adapter.java # Implémentation du port domain + DTOs internes
└── {Api}RestClientConfig.java # Bean RestClient (@Qualifier)
Les DTOs de réponse HTTP sont déclarés comme records internes à l'adaptateur — pas de fichier séparé sauf si réutilisés par plusieurs adaptateurs.
Configuration du RestClient
Un bean RestClient par API externe, injecté via @Qualifier.
@Configuration
public class CommandeRestClientConfig {
@Bean
public RestClient commandeRestClient(@Value("${commande.base-url}") String baseUrl) {
return RestClient.builder()
.baseUrl(baseUrl)
.defaultStatusHandler(HttpStatusCode::isError, (request, response) -> {
throw new CommandeApiException(response.getStatusCode(), request.getURI());
})
.build();
}
}
Adaptateur — Pattern complet
@Component
public class ApiCommandeOperationProvider implements OperationProvider {
private final RestClient commandeRestClient;
private final Counter errorsCounter;
public ApiCommandeOperationProvider(
@Qualifier("commandeRestClient") RestClient commandeRestClient,
MeterRegistry meterRegistry) {
this.commandeRestClient = commandeRestClient;
this.errorsCounter = Counter.builder("myapp_commande_errors_total")
.description("Erreurs lors des appels à l'API Commande")
.register(meterRegistry);
}
@Override
public Optional<Operation> getOperation(String insertionId) {
try {
final var insertion = commandeRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/insertions/{insertionId}")
.build(insertionId))
.retrieve()
.body(InsertionResponse.class);
if (insertion == null || insertion.operation() == null) {
return Optional.empty();
}
final var iri = insertion.operation();
final var operationId = iri.substring(iri.lastIndexOf('/') + 1);
return Optional.ofNullable(commandeRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/operations/{operationId}")
.build(operationId))
.retrieve()
.body(OperationResponse.class)
).map(op -> new Operation(op.id(), op.opportuniteCrmId()));
} catch (Exception e) {
errorsCounter.increment();
throw e;
}
}
record InsertionResponse(String id, String operation) {}
record OperationResponse(String id,
@JsonProperty("opportunite_crm_id") String opportuniteCrmId) {}
}
@JsonProperty
Points clés :
@Qualifierpour désigner le bon RestClient quand plusieurs beans existent- DTOs comme records internes — déclarés dans l'adaptateur, visibilité package
- URI builder pour les URLs avec variables de chemin :
uriBuilder -> uriBuilder.path(...).build(...) - Retourner
Optional<T>plutôt quenulldepuis le port domain - Compteur Prometheus obligatoire sur le bloc try/catch englobant
❌ Interdit : retourner
null— utiliserOptional.empty()❌ Interdit : appel API sans compteur d'erreur dans l'adaptateur ❌ Interdit : avaler l'exception après.increment()
Gestion des erreurs HTTP
Centraliser dans le defaultStatusHandler du bean RestClient, pas dans chaque appel.
public class CommandeApiException extends RuntimeException {
public CommandeApiException(HttpStatusCode status, URI uri) {
super("Commande API error %s on %s".formatted(status, uri));
}
}
Authentification (TokenProvider)
Si l'API nécessite un token, utiliser un intercepteur sur le bean RestClient :
@Bean
public RestClient securedRestClient(@Value("${api.base-url}") String baseUrl,
TokenProvider tokenProvider) {
return RestClient.builder()
.baseUrl(baseUrl)
.requestInterceptor((request, body, execution) -> {
request.getHeaders().setBearerAuth(tokenProvider.getToken());
return execution.execute(request, body);
})
.build();
}
Tests avec WireMock (@RestClientTest)
Utiliser @RestClientTest (slice test Spring Boot) + WireMock pour tester l'adaptateur avec son vrai bean RestClient.
Dependance Maven: Cette dependace est dans le
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<scope>test</scope>
</dependency>
@RestClientTest(components = ApiCommandeOperationProvider.class)
@Import({CommandeRestClientConfig.class, ApiCommandeOperationProviderTest.OAuth2TestConfig.class})
class ApiCommandeOperationProviderTest {
private static WireMockServer wireMockServer;
@Autowired
private ApiCommandeOperationProvider provider;
@Autowired
private InMemoryOAuth2AuthorizedClientService authorizedClientService;
@BeforeAll
static void setUp() {
wireMockServer = new WireMockServer(options().dynamicPort());
wireMockServer.start();
WireMock.configureFor("localhost", wireMockServer.port());
}
@AfterAll
static void tearDown() {
wireMockServer.stop();
}
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.security.oauth2.client.provider.commande.token-uri",
() -> wireMockServer.baseUrl() + "/oauth/token");
registry.add("http.commande.url", wireMockServer::baseUrl);
}
@BeforeEach
void resetState() {
wireMockServer.resetAll();
authorizedClientService.removeAuthorizedClient("commande", "anonymousUser");
}
@Test
void shouldReturnMappedOperationWhenFound() {
stubTokenEndpoint();
wireMockServer.stubFor(get(urlPathEqualTo("/insertions/INS-1"))
.willReturn(okJson("""
{"id":"INS-1","operation":"/operations/OP-42"}
""")));
wireMockServer.stubFor(get(urlPathEqualTo("/operations/OP-42"))
.willReturn(okJson("""
{"id":"OP-42","opportunite_crm_id":"CRM-99","packs":[]}
""")));
final var result = provider.getOperation("INS-1");
assertThat(result).hasValueSatisfying(op -> assertThat(op.id()).isEqualTo("OP-42"));
}
@Test
void shouldReturnEmptyWhenInsertionHasNoOperation() {
stubTokenEndpoint();
wireMockServer.stubFor(get(urlPathEqualTo("/insertions/INS-1"))
.willReturn(okJson("""{"id":"INS-1","operation":null}""")));
assertThat(provider.getOperation("INS-1")).isEmpty();
}
@Test
void shouldCallInsertionThenOperationEndpoints() {
stubTokenEndpoint();
wireMockServer.stubFor(get(urlPathEqualTo("/insertions/INS-1"))
.willReturn(okJson("""{"id":"INS-1","operation":"/operations/OP-42"}""")));
wireMockServer.stubFor(get(urlPathEqualTo("/operations/OP-42"))
.willReturn(okJson("""{"id":"OP-42","opportunite_crm_id":"CRM-99","packs":[]}""")));
provider.getOperation("INS-1");
WireMock.verify(1, getRequestedFor(urlPathEqualTo("/insertions/INS-1")));
WireMock.verify(1, getRequestedFor(urlPathEqualTo("/operations/OP-42")));
}
private void stubTokenEndpoint() {
wireMockServer.stubFor(post("/oauth/token")
.willReturn(okJson("""
{"access_token":"test-token","token_type":"Bearer","expires_in":3600}
""")));
}
@TestConfiguration
static class OAuth2TestConfig {
@Bean
ClientRegistrationRepository clientRegistrationRepository(
@Value("${spring.security.oauth2.client.provider.commande.token-uri}") String tokenUri) {
final var registration = ClientRegistration.withRegistrationId("commande")
.clientId("test-client-id")
.clientSecret("test-client-secret")
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.tokenUri(tokenUri)
.build();
return new InMemoryClientRegistrationRepository(registration);
}
@Bean
InMemoryOAuth2AuthorizedClientService authorizedClientService(
ClientRegistrationRepository repository) {
return new InMemoryOAuth2AuthorizedClientService(repository);
}
}
}
Points clés :
@RestClientTestcharge uniquement le composant testé et l'auto-config RestClient — pas de contexte Spring complet@Importpour charger la config du RestClient et les beans OAuth2 de test- WireMock démarré une seule fois par classe (
@BeforeAll) —resetAll()+ clear du token OAuth2 entre chaque test @DynamicPropertySourcepour câbler l'URL WireMock dans les propriétés SpringWireMock.verify()pour asserter que les bons endpoints ont été appelés
references/infrastructure-rabbit.md
Infrastructure — Publication RabbitMQ Sortante
Applique ces spécificités uniquement si le projet publie des messages RabbitMQ (présence de
spring-boot-starter-amqpet d'unRabbitTemplatepour l'envoi de messages).
Documentation
Chaque message publié devra faire l objet d'une documentation async api Pour faire cela, utiliser springwolf
Dépendances spécifiques
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- async api doc -->
<dependency>
<groupId>io.github.springwolf</groupId>
<artifactId>springwolf-ui</artifactId>
<version>${springwolf.version}</version>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>rabbitmq</artifactId>
<scope>test</scope>
</dependency>
Structure infrastructure/
infrastructure/
└── amqp/
├── publisher/
│ └── {Domain}Publisher.java # Implémentation du port domain
├── config/
│ └── RabbitConfig.java # Sérialisation JSON uniquement
└── model/
└── {Domain}Message.java # DTO message (record)
Configuration RabbitMQ
Configurer uniquement la sérialisation JSON et les exchange allant être utilisés — les queues seront déclarées par les listeners
@Configuration
public class RabbitConfig {
@Bean
public MessageConverter jackson3JsonMessageConverter(ObjectMapper objectMapper) {
return new JacksonJsonMessageConverter(objectMapper);
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory,
MessageConverter messageConverter) {
final var template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(messageConverter);
return template;
}
}
❌ Interdit : déclarer des queues dans la config du publisher — c'est la responsabilité des listeners
Publisher — Implémentation du port domain
@Component
public class CommandePublisher implements CommandePublisherPort {
private final RabbitTemplate rabbitTemplate;
private final Counter errorsCounter;
public CommandePublisher(RabbitTemplate rabbitTemplate, MeterRegistry meterRegistry) {
this.rabbitTemplate = rabbitTemplate;
this.errorsCounter = Counter.builder("myapp_rabbit_publish_errors_total")
.description("Erreurs lors de la publication de messages RabbitMQ")
.register(meterRegistry);
}
@Override
public void publishCommandeCreee(Commande commande) {
try {
final var message = new CommandeCreeeMessage(commande.id(), commande.reference());
rabbitTemplate.convertAndSend("commandes.exchange", "commande.creee", message);
} catch (Exception e) {
errorsCounter.increment();
throw e;
}
}
}
❌ Interdit : publication sans compteur d'erreur ❌ Interdit : avaler l'exception après
.increment()
Messages (DTOs)
Les messages sont des records immutables — pas d'annotations Jackson sauf si nécessaire.
public record CommandeCreeeMessage(String commandeId, String reference) {}
❌ Interdit : réutiliser les objets du domain comme messages — toujours mapper vers un DTO dédié
Tests avec Testcontainers
@SpringBootTest
@Testcontainers
class CommandePublisherTest {
@Container
@ServiceConnection
static RabbitMQContainer rabbitMQ = new RabbitMQContainer("rabbitmq:3.12-management");
@Autowired
private CommandePublisher publisher;
@Autowired
private RabbitTemplate rabbitTemplate;
@Test
void shouldPublishCommandeCreeeMessage() {
final var commande = new Commande("CMD-123", "REF-001");
publisher.publishCommandeCreee(commande);
final var received = (CommandeCreeeMessage) rabbitTemplate.receiveAndConvert(
"commandes.queue", 3000);
assertThat(received).isNotNull();
assertThat(received.commandeId()).isEqualTo("CMD-123");
}
@Test
void shouldIncrementErrorCounterOnPublishFailure() {
// Simuler une connexion coupée en arrêtant le container
rabbitMQ.stop();
assertThatThrownBy(() -> publisher.publishCommandeCreee(new Commande("CMD-123", "REF-001")))
.isInstanceOf(Exception.class);
final var counter = meterRegistry.find("myapp_rabbit_publish_errors_total").counter();
assertThat(counter.count()).isEqualTo(1.0);
}
}
references/infrastructure-jpa-postgre.md
Infrastructure — Persistance (Base de Données)
**Applique ces spécificités uniquement si le projet persiste des données avec spring jpa (présence de
spring-boot-starter-data-jpaou équivalent dans le pom.xml).
Dépendances spécifiques
PostgreSQL / MySQL (JPA)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
Structure infrastructure/
infrastructure/
└── database/
├── entity/
│ └── {Domain}Entity.java # Entité JPA / Document Mongo
├── repository/
│ ├── {Domain}JpaRepository.java # Interface Spring Data
│ └── {Domain}RepositoryAdapter.java # Implémentation du port domain
└── mapper/
└── {Domain}EntityMapper.java # Entity ↔ Domain
Entités JPA
Les entités JPA sont séparées des objets domain. Elles vivent uniquement dans l'infrastructure.
@Entity
@Table(name = "commandes")
public class CommandeEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@Column(nullable = false)
private String reference;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private StatutCommande statut;
@CreationTimestamp
private Instant createdAt;
// Constructeur no-arg requis par JPA
protected CommandeEntity() {}
public CommandeEntity(String reference, StatutCommande statut) {
this.reference = reference;
this.statut = statut;
}
// Getters uniquement — pas de setters
}
❌ Interdit : réutiliser les records domain comme entités JPA ❌ Interdit : setters sur les entités — utiliser le constructeur
Repository Spring Data
public interface CommandeJpaRepository extends JpaRepository<CommandeEntity, String> {
List<CommandeEntity> findByStatut(StatutCommande statut);
@Query("SELECT c FROM CommandeEntity c WHERE c.reference LIKE :prefix%")
List<CommandeEntity> findByReferencePrefix(@Param("prefix") String prefix);
}
Adaptateur — Implémentation du port domain
@Component
public class CommandeRepositoryAdapter implements CommandeRepositoryPort {
private final CommandeJpaRepository jpaRepository;
public CommandeRepositoryAdapter(CommandeJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Commande save(Commande commande) {
final var entity = CommandeEntityMapper.toEntity(commande);
final var saved = jpaRepository.save(entity);
return CommandeEntityMapper.toDomain(saved);
}
@Override
public Optional<Commande> findById(String id) {
return jpaRepository.findById(id).map(CommandeEntityMapper::toDomain);
}
@Override
public List<Commande> findByStatut(StatutCommande statut) {
return jpaRepository.findByStatut(statut).stream()
.map(CommandeEntityMapper::toDomain)
.toList();
}
}
Pas de compteur Prometheus ici — les erreurs de base de données déclenchent des exceptions Spring qui remontent naturellement.
Mapper Entity ↔ Domain
Les mappers sont des classes utilitaires immutables : final class, constructeur private, méthodes static.
Pas de @Component, pas d'état.
public final class CommandeEntityMapper {
private CommandeEntityMapper() {}
public static Commande toDomain(CommandeEntity entity) {
return new Commande(entity.getId(), entity.getReference(), entity.getStatut());
}
public static CommandeEntity toEntity(Commande commande) {
return new CommandeEntity(commande.reference(), commande.statut());
}
}
Migrations Flyway
src/main/resources/db/migration/
├── V1__create_commandes_table.sql
└── V2__add_index_on_statut.sql
-- V1__create_commandes_table.sql
CREATE TABLE commandes (
id VARCHAR(36) PRIMARY KEY,
reference VARCHAR(255) NOT NULL,
statut VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
❌ Interdit : modifier une migration existante — toujours créer une nouvelle version
Tests avec Testcontainers (@DataJpaTest)
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class CommandeRepositoryAdapterTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired
private CommandeJpaRepository jpaRepository;
private CommandeRepositoryAdapter adapter;
@BeforeEach
void setUp() {
adapter = new CommandeRepositoryAdapter(jpaRepository);
}
@Test
void shouldSaveAndRetrieveCommande() {
final var commande = new Commande(null, "REF-001", StatutCommande.EN_COURS);
final var saved = adapter.save(commande);
assertThat(saved.id()).isNotNull();
final var found = adapter.findById(saved.id());
assertThat(found).hasValueSatisfying(c -> assertThat(c.reference()).isEqualTo("REF-001"));
}
@Test
void shouldReturnEmptyWhenCommandeNotFound() {
final var result = adapter.findById("unknown-id");
assertThat(result).isEmpty();
}
@Test
void shouldFindCommandesByStatut() {
adapter.save(new Commande(null, "REF-001", StatutCommande.EN_COURS));
adapter.save(new Commande(null, "REF-002", StatutCommande.TERMINE));
final var results = adapter.findByStatut(StatutCommande.EN_COURS);
assertThat(results).hasSize(1);
assertThat(results.getFirst().reference()).isEqualTo("REF-001");
}
}