terminal Claude Marketplace Browser
SKILL v1.2.2

java-dev-plugin

Développement Java/Spring Boot avec architecture DDD hexagonale (Spring Boot 4, Java 25)

Author Mathieu Durand
Version 1.2.2

Components

bolt
java-backend Skill
expand_more

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.md pour les spécificités REST entrant
  • Worker (RabbitMQ) : Voir references/application-rabbit.md pour les spécificités messaging entrant

Ressources complémentaires — Couches sortantes :

  • Appels HTTP sortants : Voir references/infrastructure-http.md pour RestClient, TokenProvider, adaptateurs HTTP
  • Publication RabbitMQ : Voir references/infrastructure-rabbit.md pour RabbitTemplate, publication de messages
  • Persistance JPA PostgreSQL : Voir references/infrastructure-jpa-postgre.md pour 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, constructeur private, toutes les méthodes static — 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 --- avec on-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 :

  1. Buildpacks (spring-boot:build-image) — aucun Dockerfile à maintenir, image optimisée automatiquement
  2. 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 expand_more

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 expand_more

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 expand_more

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 :

  • @Qualifier pour 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 que null depuis le port domain
  • Compteur Prometheus obligatoire sur le bloc try/catch englobant

Interdit : retourner null — utiliser Optional.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 :

  • @RestClientTest charge uniquement le composant testé et l'auto-config RestClient — pas de contexte Spring complet
  • @Import pour 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
  • @DynamicPropertySource pour câbler l'URL WireMock dans les propriétés Spring
  • WireMock.verify() pour asserter que les bons endpoints ont été appelés
references/infrastructure-rabbit.md expand_more

Infrastructure — Publication RabbitMQ Sortante

Applique ces spécificités uniquement si le projet publie des messages RabbitMQ (présence de spring-boot-starter-amqp et d'un RabbitTemplate pour 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 expand_more

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-jpa ou é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");
    }
}