Partie 02 · Projet 2
Pourquoi les microservices ?
Avant de découper une application en services indépendants, il faut comprendre le problème concret que ça résout, les compromis que ça impose, et l'architecture qu'on va construire tout au long de ce projet.
Le monolithe : simple et puissant
Dans le Projet 1, toute l'application tenait dans un seul déploiement : un jar, une base PostgreSQL, un serveur Tomcat embarqué. C'est un monolithe.
Contrairement à ce qu'on entend souvent, le monolithe est une architecture parfaitement valide. Pour la majorité des applications — startups, SaaS en croissance, applications métier — c'est même la meilleure architecture :
- Simple à déployer — un seul artefact à déplacer sur un serveur.
- Simple à déboguer — une seule stack trace, un seul log, un seul point d'observation.
- Simple à tester — un seul contexte Spring, une seule base de données de test.
- Performant — les appels entre modules se font en mémoire, pas via le réseau.
Quand le monolithe montre ses limites
Le monolithe devient problématique à mesure que l'équipe et la codebase grandissent. Les symptômes concrets sont :
| Symptôme | Ce qui se passe | Impact |
| Déploiements bloqués | 10 équipes, un seul pipeline de déploiement. L'équipe A doit attendre l'équipe B pour déployer. | Vitesse de livraison divisée par 10 |
| Scalabilité uniforme | Le service de traitement d'images consomme 80% du CPU. Pour scaler, il faut scaler toute l'application. | Coût d'infrastructure × 10 inutilement |
| Pannes en cascade | Un bug dans le module email provoque une fuite mémoire qui fait tomber toute l'application. | 100% d'indisponibilité pour un bug local |
| Build times | 50 000 lignes de code, 20 minutes de compilation et de tests pour chaque changement. | Feedback loop développeur trop lent |
| Tech debt contrainte | On voudrait migrer le service de paiement en Go pour ses performances, mais tout est couplé. | Innovation technique bloquée |
⚠ Ne pas partir sur des microservices trop tôt
La complexité opérationnelle des microservices est réelle et significative :
réseau entre services (latence, pannes), découverte de services, configuration distribuée,
observabilité multi-services, transactions distribuées, gestion des pannes en cascade.
Pour une startup ou un projet solo, le monolithe bien structuré du Projet 1 est souvent
le bon point de départ. Les microservices sont une solution à des problèmes d'organisation
et de scale — pas un objectif architectural en soi.
Martin Fowler résume ça bien : "Don't start with microservices. Monoliths first."
Architecture du Projet 2
On construit une plateforme e-commerce minimale en 4 services indépendants. Chaque service est un projet Spring Boot autonome avec sa propre base de données, son propre cycle de déploiement et son propre dépôt Git (en théorie).
Client HTTP
Navigateur, app mobile, Postman — connaît une seule adresse
↓ toutes les requêtes passent ici
api-gateway :8080
Point d'entrée unique. Route, valide JWT, gère CORS. Spring Cloud Gateway (WebFlux)
↓ routage selon le préfixe /api/users, /api/products, /api/orders
user-service :8081
Gestion des utilisateurs, authentification, génération JWT. PostgreSQL dédiée.
product-service :8082
Catalogue produits, gestion du stock. PostgreSQL dédiée.
order-service :8083
Commandes. Appelle product-service via OpenFeign. PostgreSQL dédiée.
↑ chaque service s'enregistre ici au démarrage
eureka-server :8761
Annuaire de services. Résout 'product-service' en 192.168.x.x:8082.
On ajoute aussi un Config Server (port 8888) qui centralise la configuration de tous les services dans un dépôt Git.
Structure du projet multi-module
jeandecode-ecommerce/
│
├── pom.xml # parent Maven — gère toutes les versions
│
├── eureka-server/ # annuaire de services (Partie 3.2)
│ ├── pom.xml
│ └── src/main/java/fr/jeandecode/eureka/
│ └── EurekaServerApplication.java
│
├── api-gateway/ # point d'entrée unique (Partie 3.3)
│ ├── pom.xml
│ └── src/main/java/fr/jeandecode/gateway/
│ ├── ApiGatewayApplication.java
│ ├── filter/JwtGlobalFilter.java
│ └── config/CorsConfig.java
│
├── config-server/ # configuration centralisée (Partie 3.4)
│ ├── pom.xml
│ └── src/main/java/fr/jeandecode/config/
│ └── ConfigServerApplication.java
│
├── user-service/ # service utilisateurs
│ ├── pom.xml
│ └── src/main/java/fr/jeandecode/user/
│ ├── UserServiceApplication.java
│ ├── controller/UserController.java
│ ├── service/UserService.java
│ ├── repository/UserRepository.java
│ ├── entity/User.java
│ ├── dto/
│ └── security/SecurityConfig.java
│
├── product-service/ # service produits
│ └── src/main/java/fr/jeandecode/product/
│
├── order-service/ # service commandes (appelle product-service)
│ └── src/main/java/fr/jeandecode/order/
│ ├── client/ProductClient.java # interface Feign
│ ├── client/ProductClientFallback.java
│ └── config/FeignConfig.java
│
└── docker-compose.yml # démarre toute l'infrastructure en local
Un service = un projet Maven autonome — chaque dossier a son propre pom.xml et peut être compilé, testé et déployé indépendamment. Le pom.xml racine est un parent Maven qui permet aussi de tout builder en une commande depuis la racine.
pom.xml parent — BOM Spring Cloud
Le pom.xml à la racine du projet a deux rôles : hériter des versions Spring Boot et importer le BOM Spring Cloud. Un BOM (Bill of Materials) est un fichier Maven qui déclare les versions exactes de toutes les bibliothèques d'un écosystème — on importe le BOM une fois, et tous les modules en héritent sans jamais spécifier de version individuelle.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<!-- Hérite de Spring Boot 4.1 qui gère déjà des centaines de versions -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
</parent>
<groupId>fr.jeandecode</groupId>
<artifactId>jeandecode-ecommerce</artifactId>
<packaging>pom</packaging> <!-- packaging pom = projet parent, pas un jar -->
<!-- Liste des sous-modules — Maven les compile dans cet ordre -->
<modules>
<module>eureka-server</module>
<module>config-server</module>
<module>api-gateway</module>
<module>user-service</module>
<module>product-service</module>
<module>order-service</module>
</modules>
<properties>
<java.version>21</java.version>
<!-- Spring Cloud 2025.1 "Oakwood" — aligné sur Spring Boot 4.x -->
<spring-cloud.version>2025.1.0</spring-cloud.version>
</properties>
<!-- dependencyManagement ≠ dependencies -->
<!-- Ici on déclare les versions, pas les dépendances réelles -->
<!-- Chaque sous-module ajoute ce qu'il veut sans spécifier de version -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope> <!-- import = "ajoute toutes ces versions dans notre gestion" -->
</dependency>
</dependencies>
</dependencyManagement>
</project>
dependencyManagement vs dependencies — dependencyManagement déclare des versions disponibles, sans les ajouter au classpath. dependencies les ajoute réellement. Un sous-module écrit <dependency>spring-cloud-starter-gateway</dependency> sans version — Maven la trouve dans le BOM importé par le parent.
✅ Récapitulatif — ce chapitre en pratique
Fichiers à créer
jeandecode-ecommerce/pom.xmljeandecode-ecommerce/docker-compose.yml
Étapes
- Créer le dossier racine
jeandecode-ecommerce/ - Créer le
pom.xml parent avec <packaging>pom</packaging> et le BOM Spring Cloud - Créer les sous-dossiers pour chaque service
- Lancer
mvn clean install -DskipTests depuis la racine pour vérifier que tout compile
Partie 02 · Projet 2
Service Discovery — Eureka
Comment un service trouve-t-il l'adresse d'un autre service sans configuration manuelle ? En cloud ou Docker, les adresses IP changent à chaque redémarrage. Eureka résout ce problème : c'est l'annuaire téléphonique des microservices.
Le problème concret
Dans le Projet 1, ArticleService appelait ArticleRepository en mémoire. En microservices, order-service doit appeler product-service via HTTP. Mais à quelle adresse ? 192.168.1.42:8082 aujourd'hui — mais demain après un redémarrage Docker, ce sera peut-être 172.17.0.5:8082.
La solution naïve — coder les adresses en dur dans un fichier de config — est fragile et ne supporte pas le scaling horizontal. Eureka résout ça :
- Chaque service démarre et s'enregistre auprès d'Eureka avec son nom logique (
product-service) et son adresse IP réelle.
- Quand
order-service veut appeler product-service, il demande à Eureka : « où est product-service en ce moment ?»
- Eureka répond avec l'adresse IP:port actuelle — et s'il y a plusieurs instances, il fait le load balancing automatiquement.
order-service
Veut appeler product-service
1. GET /eureka/apps/PRODUCT-SERVICE
Eureka Server :8761
Retourne : {ipAddr: '10.0.0.5', port: 8082, status: 'UP'}
2. GET http://10.0.0.5:8082/api/products/42
product-service
Répond avec les données
Créer le serveur Eureka
Le serveur Eureka est lui-même un projet Spring Boot — c'est la beauté de l'écosystème Spring Cloud. Une seule annotation suffit.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<!-- Hérite du parent — récupère les versions Spring Boot + Spring Cloud -->
<parent>
<groupId>fr.jeandecode</groupId>
<artifactId>jeandecode-ecommerce</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>eureka-server</artifactId>
<dependencies>
<!-- Le starter Eureka Server — contient tout ce qu'il faut -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
<!-- pas de version : gérée par le BOM du parent -->
</dependency>
<!-- Actuator : expose /actuator/health pour les health checks -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
</project>
@SpringBootApplication
@EnableEurekaServer // ← c'est tout. Spring configure tout le reste automatiquement
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
server:
port: 8761 # port standard d'Eureka — convention à respecter
spring:
application:
name: eureka-server
eureka:
client:
register-with-eureka: false # le serveur ne s'enregistre pas lui-même dans son propre annuaire
fetch-registry: false # idem, ne récupère pas son propre registre
server:
wait-time-in-ms-when-sync-empty: 0 # démarre immédiatement sans attendre d'autres noeuds
register-with-eureka: false — sans cette option, le serveur Eureka essaierait de s'enregistrer lui-même dans son propre annuaire et de synchroniser avec d'autres noeuds Eureka (pour la haute disponibilité). En développement avec une seule instance, on désactive les deux.
Enregistrer un service dans Eureka
Chaque microservice (user-service, product-service, order-service) doit s'enregistrer dans Eureka au démarrage. On appelle ça être un client Eureka. La configuration est identique pour chaque service — seul le nom logique et le port changent.
Voici la configuration complète de user-service comme exemple :
<dependencies>
<!-- API REST -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Client Eureka — s'enregistre dans l'annuaire au démarrage + heartbeat toutes les 30s -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- Autres dépendances identiques au Projet 1 -->
<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.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
@SpringBootApplication
// @EnableDiscoveryClient est facultatif depuis Spring Cloud 3+ :
// la présence du starter eureka-client dans le pom.xml suffit à l'activer automatiquement.
// On peut l'écrire quand même pour que l'intention soit claire dans le code.
@EnableDiscoveryClient
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}
server:
port: 8081
spring:
application:
# Ce nom est CRUCIAL : c'est l'identifiant du service dans Eureka
# C'est aussi ce nom qu'on utilise dans lb://user-service (Gateway) et
# @FeignClient(name = "user-service") (OpenFeign)
name: user-service
datasource:
url: jdbc:postgresql://localhost:5433/user_db
username: postgres
password: postgres
jpa:
hibernate:
ddl-auto: update
# Configuration de l'enregistrement Eureka
eureka:
client:
service-url:
# URL du serveur Eureka — en prod, on mettrait l'URL via Config Server
defaultZone: http://localhost:8761/eureka/
instance:
prefer-ip-address: true # enregistre l'IP plutôt que le hostname — plus fiable en Docker
spring.application.name — c'est l'identifiant logique du service dans toute l'architecture. Il apparaît dans le dashboard Eureka (http://localhost:8761), dans les routes de la Gateway (lb://user-service) et dans les clients Feign. Choisir un nom stable et en minuscules avec tirets.
Heartbeat et détection de pannes
Une fois enregistré, chaque client Eureka envoie un heartbeat (signal « je suis vivant ») toutes les 30 secondes. Si Eureka ne reçoit pas de heartbeat pendant 90 secondes, il considère le service en panne et le retire de l'annuaire. Les autres services ne lui enverront plus de requêtes.
En développement, ces délais peuvent être raccourcis pour accélérer la détection :
# Configuration Eureka pour le développement — délais raccourcis
eureka:
client:
registry-fetch-interval-seconds: 5 # récupère la liste des services toutes les 5s (défaut: 30s)
instance:
lease-renewal-interval-in-seconds: 5 # heartbeat toutes les 5s (défaut: 30s)
lease-expiration-duration-in-seconds: 10 # expiration si pas de heartbeat pendant 10s (défaut: 90s)
En production — garder les valeurs par défaut (30s/90s). Les raccourcir en développement uniquement pour ne pas attendre 90s après un arrêt de service.
Docker Compose — bases de données séparées
Règle fondamentale des microservices : chaque service possède sa propre base de données. Deux services ne partagent jamais une base. Si order-service et product-service partageaient la même base, ils seraient couplés au niveau des données — il faudrait coordonner les migrations, les deux pourraient lire/modifier les données de l'autre. On perd tous les avantages du découpage.
version: '3.9'
services:
# ─── Bases de données : une par service ───────────────────
# Chaque service a un port exposé différent en local (5433, 5434, 5435)
# pour éviter les conflits — en prod/Docker, chaque service accède
# à sa base via le nom de service DNS interne (postgres-user:5432)
postgres-user:
image: postgres:16-alpine
environment:
POSTGRES_DB: user_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5433:5432" # host:container — port 5433 sur la machine locale
volumes:
- postgres_user_data:/var/lib/postgresql/data
postgres-product:
image: postgres:16-alpine
environment:
POSTGRES_DB: product_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5434:5432"
volumes:
- postgres_product_data:/var/lib/postgresql/data
postgres-order:
image: postgres:16-alpine
environment:
POSTGRES_DB: order_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5435:5432"
volumes:
- postgres_order_data:/var/lib/postgresql/data
volumes:
postgres_user_data:
postgres_product_data:
postgres_order_data:
Ordre de démarrage en local — démarrer les services dans cet ordre : 1) docker-compose up (bases de données), 2) eureka-server, 3) config-server, 4) les services métier (user-service, product-service, order-service), 5) api-gateway. Chacun doit attendre qu'Eureka soit disponible.
✅ Récapitulatif — ce chapitre en pratique
Fichiers à créer
jeandecode-ecommerce/eureka-server/pom.xmljeandecode-ecommerce/eureka-server/src/main/java/fr/jeandecode/eureka/EurekaServerApplication.javajeandecode-ecommerce/eureka-server/src/main/resources/application.ymljeandecode-ecommerce/user-service/src/main/resources/application.yml (config Eureka)jeandecode-ecommerce/docker-compose.yml
Étapes
- Créer le module
eureka-server avec son pom.xml héritant du parent - Ajouter
@EnableEurekaServer sur la classe principale - Configurer le port 8761 et désactiver l'auto-enregistrement
- Ajouter
spring-cloud-starter-netflix-eureka-client dans chaque service métier - Configurer
spring.application.name et eureka.client.service-url.defaultZone dans chaque service - Lancer Eureka et vérifier le dashboard sur
http://localhost:8761 - Démarrer un service et vérifier qu'il apparaît dans le dashboard
Partie 02 · Projet 2
Spring Cloud Gateway
L'API Gateway est le seul point d'entrée de l'architecture. Les clients ne connaissent qu'une seule adresse — la Gateway. Elle route les requêtes, valide les JWT, gère le CORS, et peut appliquer du rate limiting. C'est le gardien de toute l'architecture.
Pourquoi une Gateway ?
Sans Gateway, les clients devraient :
- Connaître l'adresse de chaque service (
user-service:8081, product-service:8082…)
- Gérer eux-mêmes la découverte et le load balancing
- Dupliquer la logique d'authentification JWT dans chaque appel
- Gérer le CORS sur chaque service indépendamment
Avec la Gateway, tout ça est centralisé : une seule adresse, une seule responsabilité.
Important : la Gateway est réactive (WebFlux)
Spring Cloud Gateway est construit sur Spring WebFlux (modèle réactif non-bloquant) et non sur Spring MVC (modèle thread-per-request). C'est une contrainte importante à comprendre :
| Spring MVC (Projet 1) | Spring WebFlux (Gateway) |
| Modèle | Thread par requête — bloquant | Événementiel — non-bloquant |
| Retour de méthode | void, ResponseEntity | Mono<Void>, Flux<T> |
| Requête HTTP | HttpServletRequest | ServerHttpRequest |
| Contexte | SecurityContextHolder (ThreadLocal) | ReactiveSecurityContextHolder |
| Contrôleurs | @RestController classiques | Non recommandé — tout se configure en YAML |
⛔ Ne jamais mélanger WebMVC et WebFlux dans la Gateway
Ne jamais ajouter spring-boot-starter-webmvc dans le pom.xml de la Gateway. Les deux modules sont incompatibles — Spring Boot ne sait pas lequel choisir et l'application ne démarre pas. La Gateway n'a pas de contrôleurs Spring MVC : toute sa configuration passe par application.yml et des GlobalFilter.
Créer la Gateway
<dependencies>
<!-- Spring Cloud Gateway — inclut WebFlux automatiquement -->
<!-- NE PAS ajouter spring-boot-starter-webmvc ici -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- Client Eureka — pour résoudre lb://user-service en vraie adresse IP -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- Actuator — pour /actuator/health utilisé par les health checks -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- JJWT — pour valider le token JWT dans le GlobalFilter -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
</dependencies>
@SpringBootApplication
public class ApiGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ApiGatewayApplication.class, args);
}
// Aucune annotation spéciale nécessaire :
// Gateway est activée par la présence du starter dans le pom.xml
// Eureka Client est activé par la présence de son starter
}
Configuration des routes
Toute la configuration des routes se fait dans application.yml. Comprendre la syntaxe des routes est essentiel :
id — nom unique de la route (pour les logs et le monitoring)
uri: lb://user-service — lb:// dit à la Gateway d'interroger Eureka pour résoudre user-service en adresse réelle, avec load balancing
predicates — conditions pour que la route s'applique (ici, le préfixe du chemin)
filters — transformations appliquées à la requête ou à la réponse
server:
port: 8080
spring:
application:
name: api-gateway
cloud:
gateway:
discovery:
locator:
enabled: true # génère automatiquement des routes pour tous les services Eureka
lower-case-service-id: true # permet /user-service/... en minuscules
routes:
# ─── Route vers user-service ────────────────────────
- id: user-service-route
uri: lb://user-service # lb:// = résolution via Eureka + load balancing
predicates:
- Path=/api/users/** # toute requête /api/users/* est envoyée ici
filters:
- StripPrefix=0 # StripPrefix=0 = garder le préfixe /api/users intact
# StripPrefix=1 = retirer /api/users avant de forwarder
# ─── Route vers product-service ─────────────────────
- id: product-service-route
uri: lb://product-service
predicates:
- Path=/api/products/**
# ─── Route vers order-service ───────────────────────
- id: order-service-route
uri: lb://order-service
predicates:
- Path=/api/orders/**
# Configuration globale
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
# Connexion à Eureka
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
management:
endpoints:
web:
exposure:
include: health,info,gateway
Filtre JWT global
Un GlobalFilter s'applique à toutes les routes. On l'utilise ici pour valider le JWT avant de laisser passer la requête. Si le token est valide, on extrait l'email et le rôle de l'utilisateur et on les ajoute comme headers HTTP — les services en aval pourront les lire directement sans toucher au JWT.
@Component
@RequiredArgsConstructor
public class JwtGlobalFilter implements GlobalFilter, Ordered {
private final JwtService jwtService;
// Routes publiques qui ne nécessitent pas de token JWT
private static final List PUBLIC_PATHS = List.of(
"/api/users/auth/register",
"/api/users/auth/login",
"/actuator"
);
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getPath().value();
// Laisser passer les routes publiques sans vérification
if (PUBLIC_PATHS.stream().anyMatch(path::startsWith)) {
return chain.filter(exchange);
}
// Extraire le header Authorization
String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
// 401 Unauthorized — pas de token ou format invalide
return reject(exchange, HttpStatus.UNAUTHORIZED);
}
String token = authHeader.substring(7); // retirer "Bearer "
if (!jwtService.isTokenValid(token)) {
return reject(exchange, HttpStatus.UNAUTHORIZED);
}
// Token valide — extraire les infos et les propager via des headers
// Les services en aval liront X-User-Email et X-User-Role
// sans avoir besoin de valider le JWT eux-mêmes
String email = jwtService.extractUsername(token);
String role = jwtService.extractRole(token);
ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.header("X-User-Email", email)
.header("X-User-Role", role)
.build();
return chain.filter(exchange.mutate().request(mutatedRequest).build());
}
private Mono reject(ServerWebExchange exchange, HttpStatus status) {
exchange.getResponse().setStatusCode(status);
return exchange.getResponse().setComplete();
}
@Override
public int getOrder() {
return -1; // priorité haute — s'exécute avant tous les autres filtres
}
}
@Configuration
public class CorsConfig {
// Centraliser la config CORS ici évite de la dupliquer dans chaque service
// Les services internes n'ont plus besoin de leur propre @CrossOrigin
@Bean
public CorsWebFilter corsWebFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("https://jeandecode.fr");
config.addAllowedOrigin("http://localhost:3000"); // dev React local
config.addAllowedMethod("*"); // GET, POST, PUT, DELETE, OPTIONS
config.addAllowedHeader("*"); // Authorization, Content-Type, etc.
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsWebFilter(source);
}
}
request.mutate() — en WebFlux, les objets ServerHttpRequest sont immuables. Pour modifier les headers, on crée une nouvelle instance via la méthode mutate(). C'est le même principe qu'un record Java immuable.
✅ Récapitulatif — ce chapitre en pratique
Fichiers à créer
jeandecode-ecommerce/api-gateway/pom.xmljeandecode-ecommerce/api-gateway/src/main/java/fr/jeandecode/gateway/ApiGatewayApplication.javajeandecode-ecommerce/api-gateway/src/main/resources/application.ymljeandecode-ecommerce/api-gateway/src/main/java/fr/jeandecode/gateway/filter/JwtGlobalFilter.javajeandecode-ecommerce/api-gateway/src/main/java/fr/jeandecode/gateway/security/JwtService.javajeandecode-ecommerce/api-gateway/src/main/java/fr/jeandecode/gateway/config/CorsConfig.java
Étapes
- Créer le module
api-gateway avec son pom.xml (WebFlux + Eureka Client + JJWT) - Configurer les routes dans
application.yml avec lb://nom-service - Implémenter
JwtGlobalFilter (implements GlobalFilter, Ordered) - Copier le
JwtService du Projet 1 dans la gateway (ou extraire dans un module partagé) - Implémenter
CorsConfig avec CorsWebFilter - Tester : démarrer Eureka + un service + la Gateway, vérifier le routage via Postman
Partie 02 · Projet 2
Config Server & OpenFeign
Ce chapitre couvre deux notions complémentaires : le Config Server centralise les configurations de tous les services dans un dépôt Git, et OpenFeign permet d'appeler un service depuis un autre de façon aussi simple qu'un appel de méthode local.
Le problème de configuration distribuée
Avec 6 services, chacun a son propre application.yml. Si on veut changer le secret JWT, on doit modifier 6 fichiers et redéployer 6 services. Si on veut changer l'URL de la base de données de staging, idem. Le Config Server centralise tout ça.
Le principe est simple : on crée un dépôt Git qui contient un fichier de configuration par service. Le Config Server sert ces fichiers à la demande. Quand un service démarre, il demande sa configuration au Config Server avant de faire quoi que ce soit d'autre.
Créer le Config Server
<dependencies>
<!-- Config Server — sert les fichiers de config depuis un dépôt Git -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
<!-- S'enregistre aussi dans Eureka pour être découvrable -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
</dependencies>
@SpringBootApplication
@EnableConfigServer // une seule annotation — Spring configure tout le reste
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}
server:
port: 8888 # port standard du Config Server
spring:
application:
name: config-server
cloud:
config:
server:
git:
# Le dépôt Git qui contient les configs de tous les services
uri: https://github.com/jeandecode/spring-config-repo
default-label: main # branche Git utilisée
# Cherche d'abord dans un sous-dossier portant le nom du service
search-paths: '{application}'
# Pour un dépôt privé, ajouter username et password (ou SSH)
# username: ${GIT_USERNAME}
# password: ${GIT_PASSWORD}
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
Structure du dépôt Git de configs
Le dépôt Git référencé contient un fichier par service. Le Config Server cherche le fichier dont le nom correspond à spring.application.name du service :
spring-config-repo/ # dépôt Git séparé, ex: github.com/jeandecode/spring-config-repo
│
├── application.yml # config commune à TOUS les services (base de données par défaut, etc.)
│
├── user-service.yml # config spécifique à user-service
├── product-service.yml # config spécifique à product-service
├── order-service.yml # config spécifique à order-service
├── api-gateway.yml # config spécifique à api-gateway
│
└── application-prod.yml # surcharge pour le profil "prod" (tous services)
# Configuration de user-service — servie par le Config Server
# Ces valeurs surchargent celles du application.yml local de user-service
spring:
datasource:
url: jdbc:postgresql://postgres-user:5432/user_db
username: postgres
password: ${DB_PASSWORD} # variable d'environnement — jamais de secret en clair dans Git
app:
jwt:
secret: ${JWT_SECRET}
expiration-ms: 86400000 # 24 heures
Utiliser le Config Server dans les services
Côté service, il suffit d'ajouter un starter et une ligne de configuration. Le service récupère sa config au démarrage, avant même d'initialiser le contexte Spring.
<!-- Ajouter dans chaque service qui utilise le Config Server -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
spring:
application:
name: user-service # détermine quel fichier lire sur le Config Server
config:
# "optional:" = si le Config Server est indisponible, utiliser les valeurs locales
# Utile en développement. En production : retirer "optional:" pour forcer la dépendance
import: optional:configserver:http://localhost:8888
# Ces valeurs locales seront surchargées par le Config Server si disponible
datasource:
url: jdbc:postgresql://localhost:5433/user_db
username: postgres
password: postgres
Priorité des configurations — Spring Boot applique les configs dans cet ordre (du moins prioritaire au plus prioritaire) : valeurs par défaut → application.yml local → Config Server → variables d'environnement → arguments de ligne de commande. Le Config Server surcharge le fichier local.
OpenFeign — appels inter-services
order-service a besoin de connaître les détails d'un produit et de diminuer son stock quand une commande est passée. Sans Feign, il faudrait écrire ça :
// ❌ Sans Feign — code verbeux, gestion manuelle des erreurs, URL codée en dur
@Service
public class OrderService {
private final RestTemplate restTemplate;
public OrderResponse createOrder(CreateOrderRequest req) {
// URL codée en dur — ne supporte pas le load balancing ni Eureka
String url = "http://product-service/api/products/" + req.productId();
try {
ProductResponse product = restTemplate.getForObject(url, ProductResponse.class);
// gérer les exceptions HTTP manuellement...
} catch (HttpClientErrorException e) {
// gérer 404, 400, etc. manuellement...
}
// ...
}
}
✓ Avec OpenFeign — une interface annotée suffit
Feign génère automatiquement le client HTTP à partir d'une interface annotée. Plus de RestTemplate, plus d'URL codée en dur, plus de gestion manuelle des exceptions. Les appels inter-services ressemblent à des appels de méthodes locaux.
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
@SpringBootApplication
@EnableFeignClients // active le scan des interfaces @FeignClient dans le package courant
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
// Interface uniquement — Spring Cloud génère l'implémentation HTTP au démarrage
// Feign utilise le nom "product-service" pour interroger Eureka et obtenir l'adresse réelle
@FeignClient(
name = "product-service", // doit correspondre à spring.application.name du service cible
fallback = ProductClientFallback.class // appelé si product-service est indisponible (Circuit Breaker)
)
public interface ProductClient {
// GET http://product-service/api/products/{id}
// Feign construit l'URL, sérialise/désérialise le JSON, gère les erreurs HTTP
@GetMapping("/api/products/{id}")
ProductResponse getProduct(@PathVariable Long id);
// PUT http://product-service/api/products/{id}/stock
@PutMapping("/api/products/{id}/stock")
void decreaseStock(@PathVariable Long id, @RequestBody StockRequest req);
}
// Implémentation de secours — appelée si product-service ne répond pas
// Évite que la panne de product-service fasse planter order-service
@Component
public class ProductClientFallback implements ProductClient {
@Override
public ProductResponse getProduct(Long id) {
// Retourner une réponse neutre plutôt que de propager l'exception
// En production : mettre en cache la dernière réponse connue
return ProductResponse.unavailable(id);
}
@Override
public void decreaseStock(Long id, StockRequest req) {
// Logguer pour traitement différé (ex: message queue)
// En production : publier un événement dans une file Kafka
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
// Feign injecté exactement comme un @Service — aucune différence d'usage
private final ProductClient productClient;
public OrderResponse createOrder(CreateOrderRequest req) {
// Appel HTTP transparent — ressemble à un appel de méthode local
ProductResponse product = productClient.getProduct(req.productId());
if (product.isUnavailable()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Produit " + req.productId() + " indisponible");
}
// Diminuer le stock dans product-service via un second appel Feign
productClient.decreaseStock(req.productId(), new StockRequest(req.quantity()));
// Créer la commande localement dans la base order_db
Order order = new Order();
order.setProductId(req.productId());
order.setQuantity(req.quantity());
order.setTotalPrice(product.price().multiply(BigDecimal.valueOf(req.quantity())));
order.setStatus(OrderStatus.PENDING);
return toResponse(orderRepository.save(order));
}
}
Feign + Eureka = load balancing automatique — si plusieurs instances de product-service sont enregistrées dans Eureka, Feign répartit les appels entre elles automatiquement (round-robin par défaut). Aucun code supplémentaire nécessaire.
✅ Récapitulatif — ce chapitre en pratique
Fichiers à créer
jeandecode-ecommerce/config-server/pom.xmljeandecode-ecommerce/config-server/src/main/java/fr/jeandecode/config/ConfigServerApplication.javajeandecode-ecommerce/config-server/src/main/resources/application.ymlspring-config-repo/user-service.yml (dépôt Git séparé)jeandecode-ecommerce/order-service/src/main/java/fr/jeandecode/order/client/ProductClient.javajeandecode-ecommerce/order-service/src/main/java/fr/jeandecode/order/client/ProductClientFallback.java
Étapes
- Créer un dépôt Git public (ex: GitHub) pour les configurations
- Y ajouter un fichier
user-service.yml, product-service.yml, etc. - Créer le module
config-server avec @EnableConfigServer - Configurer l'URI du dépôt Git dans
application.yml du Config Server - Ajouter
spring-cloud-starter-config dans chaque service client - Configurer
spring.config.import: optional:configserver:http://localhost:8888 - Ajouter
@EnableFeignClients dans OrderServiceApplication - Créer l'interface
ProductClient avec @FeignClient(name = "product-service") - Créer
ProductClientFallback — une implémentation de secours - Tester : passer une commande via Postman et vérifier que le stock est diminué dans product-service
Partie 02 · Projet 2
Résilience & Circuit Breaker
En microservices, les pannes partielles sont inévitables. Sans mécanisme de protection, une panne d'un seul service peut faire tomber en cascade toute l'architecture. Resilience4j implémente les patterns de résilience standard : Circuit Breaker, Retry, TimeLimiter et Bulkhead.
La panne en cascade — le scénario catastrophe
Imaginons que product-service soit lent (timeout à 30 secondes). Voici ce qui se passe sans protection :
- Un client envoie une commande à
order-service
order-service appelle product-service via Feign et attend
- Le thread de
order-service est bloqué 30 secondes
- 100 clients envoient des commandes → 100 threads bloqués
order-service épuise son pool de threads → il tombe à son tour
- La Gateway essaie de router vers
order-service → plus de réponse
- L'ensemble de l'architecture est indisponible à cause d'un seul service lent
C'est l'effet domino. Le Circuit Breaker l'empêche en coupant rapidement le circuit.
Comment fonctionne le Circuit Breaker
Le Circuit Breaker est un automate à 3 états :
CLOSED (fermé)
État normal. Les requêtes passent. Le CB surveille le taux d'échec.
si taux d'échec ≥ seuil → OPEN
OPEN (ouvert)
Le circuit est coupé. Toutes les requêtes reçoivent immédiatement le fallback. Aucun appel réseau.
après waitDuration → HALF-OPEN
HALF-OPEN (semi-ouvert)
Le CB laisse passer N requêtes de test. Si elles réussissent → CLOSED. Sinon → OPEN.
Ajouter Resilience4j au projet
<dependencies>
<!-- Circuit Breaker via Spring Cloud + Resilience4j -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
<!-- Inclut resilience4j-circuitbreaker, resilience4j-retry, resilience4j-timelimiter -->
</dependency>
<!-- AOP est obligatoire — les annotations @CircuitBreaker utilisent des proxies AOP -->
<!-- Sans ce starter, les annotations n'ont aucun effet ! -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Actuator expose les métriques CB sur /actuator/circuitbreakers -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
spring-boot-starter-aop est obligatoire — les annotations @CircuitBreaker, @Retry, @TimeLimiter fonctionnent grâce à AOP (programmation orientée aspect). Spring crée un proxy autour de la méthode annotée. Sans AOP, les annotations sont ignorées silencieusement.
Configuration détaillée de Resilience4j
resilience4j:
# ─── Circuit Breaker ────────────────────────────────────────
circuitbreaker:
instances:
product-service: # nom de l'instance — référencé dans @CircuitBreaker(name=...)
slidingWindowSize: 10 # analyse les 10 derniers appels pour calculer le taux d'échec
failureRateThreshold: 50 # ouvre le circuit si >= 50% des appels échouent
waitDurationInOpenState: 30s # attend 30s en état OPEN avant de passer en HALF-OPEN
permittedNumberOfCallsInHalfOpenState: 3 # 3 appels de test en état HALF-OPEN
slowCallDurationThreshold: 2s # un appel > 2s est considéré comme "lent"
slowCallRateThreshold: 80 # ouvre si >= 80% des appels sont lents
# Exceptions qui comptent comme des échecs (défaut : toutes les exceptions)
recordExceptions:
- java.io.IOException
- feign.FeignException
# ─── Retry ──────────────────────────────────────────────────
retry:
instances:
product-service:
maxAttempts: 3 # 3 tentatives au total (1 initiale + 2 retries)
waitDuration: 500ms # attendre 500ms entre chaque tentative
# Ne retenter que sur ces exceptions — éviter de retenter sur des erreurs 4xx
retryExceptions:
- java.io.IOException
- feign.RetryableException
# Ne JAMAIS retenter sur ces exceptions (ex: données invalides)
ignoreExceptions:
- feign.FeignException.BadRequest
# ─── TimeLimiter ────────────────────────────────────────────
timelimiter:
instances:
product-service:
timeoutDuration: 3s # coupe l'appel si product-service ne répond pas en 3s
# ─── Bulkhead ───────────────────────────────────────────────
bulkhead:
instances:
product-service:
maxConcurrentCalls: 10 # max 10 appels simultanés vers product-service
maxWaitDuration: 100ms # si le bulkhead est plein, attendre 100ms avant de rejeter
# Exposer les métriques Resilience4j
management:
endpoints:
web:
exposure:
include: health,circuitbreakers,circuitbreakerevents,retries
Annotations sur le service
Les annotations s'appliquent sur la méthode qui fait l'appel externe. L'ordre d'exécution est important : Retry → CircuitBreaker → TimeLimiter → Bulkhead. En pratique, cela signifie que si l'appel échoue, Retry réessaie d'abord (dans la limite du maxAttempts), puis le CircuitBreaker comptabilise l'échec global.
@Service
@RequiredArgsConstructor
public class OrderService {
private final ProductClient productClient;
private final OrderRepository orderRepository;
// Ordre d'exécution (intérieur → extérieur) : Retry → CircuitBreaker → TimeLimiter → Bulkhead
@CircuitBreaker(name = "product-service", fallbackMethod = "createOrderFallback")
@Retry(name = "product-service")
@TimeLimiter(name = "product-service")
@Bulkhead(name = "product-service", type = Bulkhead.Type.SEMAPHORE)
public OrderResponse createOrder(CreateOrderRequest req) {
ProductResponse product = productClient.getProduct(req.productId());
productClient.decreaseStock(req.productId(), new StockRequest(req.quantity()));
Order order = new Order();
order.setProductId(req.productId());
order.setQuantity(req.quantity());
order.setTotalPrice(product.price().multiply(BigDecimal.valueOf(req.quantity())));
order.setStatus(OrderStatus.PENDING);
return toResponse(orderRepository.save(order));
}
// ⚠ Règle absolue : même signature que createOrder + Throwable en dernier paramètre
// Spring appelle cette méthode quand le circuit est ouvert ou que les retries sont épuisés
public OrderResponse createOrderFallback(CreateOrderRequest req, Throwable ex) {
// Stratégie 1 : répondre immédiatement avec un statut "en attente"
// Le traitement sera repris plus tard (ex: via un job planifié ou Kafka)
return OrderResponse.builder()
.status(OrderStatus.QUEUED)
.message("Commande enregistrée. Elle sera traitée dans les prochaines minutes.")
.build();
// Stratégie 2 : propager une exception métier compréhensible
// throw new ServiceUnavailableException("Le service produit est momentanément indisponible");
}
}
La fallbackMethod doit avoir la même signature + Throwable — si la méthode principale prend (CreateOrderRequest req), le fallback prend (CreateOrderRequest req, Throwable ex). Spring inspecte les signatures par réflexion au démarrage. Un mismatch lève une exception.
✅ Récapitulatif — ce chapitre en pratique
Fichiers à créer
jeandecode-ecommerce/order-service/pom.xml (Resilience4j + AOP + Actuator)jeandecode-ecommerce/order-service/src/main/resources/application.yml (config resilience4j)jeandecode-ecommerce/order-service/src/main/java/fr/jeandecode/order/service/OrderService.java (annotations)
Étapes
- Ajouter
spring-cloud-starter-circuitbreaker-resilience4j ET spring-boot-starter-aop - Configurer le bloc
resilience4j: dans application.yml (CB + Retry + TimeLimiter + Bulkhead) - Ajouter les annotations
@CircuitBreaker, @Retry, @TimeLimiter sur createOrder - Créer la méthode
createOrderFallback avec la même signature + Throwable - Tester : arrêter
product-service, vérifier que les commandes reçoivent le fallback - Observer l'état du Circuit Breaker sur
/actuator/circuitbreakers
La stratégie : Gateway as Security Boundary
Il serait théoriquement possible de valider le JWT dans chaque service indépendamment. Mais ça implique de :
- Dupliquer
JwtService dans chaque service (ou créer un module partagé)
- Maintenir le même secret JWT dans tous les services (synchronisation complexe)
- Gérer l'expiration et le refresh dans chaque service
La stratégie Gateway as Security Boundary est beaucoup plus simple :
Client externe
Authorization: Bearer eyJhbGci...
↓ Gateway valide le JWT
api-gateway
Vérifie signature + expiration. Extrait email + role. Ajoute X-User-Email et X-User-Role.
↓ requête avec headers d'identité
user-service
@RequestHeader("X-User-Email") — lit l'identité, permitAll() en Security
product-service
Idem — les services internes ne voient jamais le JWT brut
order-service
Propage X-User-* vers product-service via RequestInterceptor Feign
Les services internes ne sont pas accessibles directement depuis l'extérieur (en production, via un réseau privé Docker/Kubernetes). Ils font confiance aux headers en provenance de la Gateway.
Spring Security dans les services internes
Puisque la Gateway est le seul point d'entrée et a déjà validé le JWT, les services internes peuvent avoir une configuration Security très simple — ils autorisent toutes les requêtes en entrée :
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// Pas de JwtAuthenticationFilter ici — la Gateway l'a déjà fait
// Pas de PasswordEncoder déclaré ici non plus si on n'expose pas de route /auth
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
// La Gateway a déjà authentifié la requête — on fait confiance
// En production, on peut ajouter une validation de l'IP source (réseau interne)
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
}
Dans les contrôleurs, on lit les headers X-User-Email et X-User-Role directement avec @RequestHeader. C'est plus simple que @AuthenticationPrincipal du Projet 1.
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
// GET /api/users/me — récupérer son propre profil
// La Gateway a injecté X-User-Email depuis le JWT — on le lit directement
@GetMapping("/me")
public ResponseEntity me(
@RequestHeader("X-User-Email") String userEmail) {
return ResponseEntity.ok(userService.findByEmail(userEmail));
}
// GET /api/users — liste tous les utilisateurs (ADMIN seulement)
// On vérifie le rôle manuellement depuis le header
// En production, on pourrait créer un @PreAuthorize custom ou un filtre AOP
@GetMapping
public ResponseEntity> listAll(
@RequestHeader("X-User-Email") String userEmail,
@RequestHeader("X-User-Role") String userRole) {
if (!"ROLE_ADMIN".equals(userRole)) {
// 403 Forbidden — l'utilisateur est authentifié mais pas autorisé
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Accès réservé aux administrateurs");
}
return ResponseEntity.ok(userService.findAll());
}
// PUT /api/users/{id} — modifier un utilisateur
// Vérifier que l'utilisateur ne modifie que son propre profil (sauf ADMIN)
@PutMapping("/{id}")
public ResponseEntity update(
@PathVariable Long id,
@Valid @RequestBody UserUpdateRequest req,
@RequestHeader("X-User-Email") String userEmail,
@RequestHeader("X-User-Role") String userRole) {
UserResponse target = userService.findById(id);
boolean isSelf = target.email().equals(userEmail);
boolean isAdmin = "ROLE_ADMIN".equals(userRole);
if (!isSelf && !isAdmin) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Vous ne pouvez modifier que votre propre profil");
}
return ResponseEntity.ok(userService.update(id, req));
}
}
Si X-User-Email est absent — Spring lève automatiquement MissingRequestHeaderException → HTTP 400. C'est le comportement souhaité : si quelqu'un accède directement à user-service sans passer par la Gateway (en bypassant le réseau interne), la requête est rejetée.
Propagation des headers dans les appels Feign
Quand order-service appelle product-service, il doit transmettre les headers d'identité pour que product-service sache quel utilisateur est à l'origine de la requête (pour les logs, l'audit, la vérification des droits).
On utilise un RequestInterceptor Feign qui copie automatiquement les headers sur tous les appels sortants :
@Configuration
public class FeignConfig {
// Un RequestInterceptor est appelé avant CHAQUE requête Feign sortante
// Il reçoit un RequestTemplate — l'objet qui représente la requête HTTP en cours de construction
@Bean
public RequestInterceptor headerPropagationInterceptor() {
return requestTemplate -> {
// Récupérer la requête HTTP courante (celle reçue par order-service)
// depuis le contexte de la requête Spring MVC
HttpServletRequest currentRequest =
((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
.getRequest();
// Copier les headers d'identité sur la requête sortante vers product-service
String email = currentRequest.getHeader("X-User-Email");
String role = currentRequest.getHeader("X-User-Role");
if (email != null) requestTemplate.header("X-User-Email", email);
if (role != null) requestTemplate.header("X-User-Role", role);
};
}
}
@FeignClient(
name = "product-service",
fallback = ProductClientFallback.class,
configuration = FeignConfig.class // appliquer l'intercepteur à CE client uniquement
)
public interface ProductClient {
@GetMapping("/api/products/{id}")
ProductResponse getProduct(@PathVariable Long id);
@PutMapping("/api/products/{id}/stock")
void decreaseStock(@PathVariable Long id, @RequestBody StockRequest req);
}
RequestContextHolder.getRequestAttributes() — fonctionne uniquement dans un contexte Spring MVC (thread par requête). En WebFlux, il faut utiliser le contexte réactif (ReactiveRequestContextHolder). Comme nos services métier utilisent Spring MVC, cette approche est correcte.
📌 Bilan du Projet 2
- Eureka — l'annuaire.
spring.application.name est le nom logique utilisé partout. Dashboard sur :8761.
- Gateway — point d'entrée unique.
lb:// pour Eureka + load balancing. WebFlux ≠ WebMVC.
- Config Server — configs Git centralisées.
optional:configserver: pour le dev.
- OpenFeign — interface annotée = client HTTP généré automatiquement.
@EnableFeignClients sur la classe principale.
- Resilience4j — CB + Retry + TimeLimiter + Bulkhead.
fallbackMethod même signature + Throwable. AOP obligatoire.
- Sécurité — JWT validé une seule fois en Gateway, propagé via
X-User-Email/X-User-Role. RequestInterceptor Feign pour la propagation en chaîne.
✅ Récapitulatif — ce chapitre en pratique
Fichiers à créer
jeandecode-ecommerce/user-service/src/main/java/fr/jeandecode/user/security/SecurityConfig.javajeandecode-ecommerce/user-service/src/main/java/fr/jeandecode/user/controller/UserController.javajeandecode-ecommerce/order-service/src/main/java/fr/jeandecode/order/config/FeignConfig.javajeandecode-ecommerce/order-service/src/main/java/fr/jeandecode/order/client/ProductClient.java (+ configuration = FeignConfig.class)
Étapes
- Configurer
SecurityConfig avec permitAll() dans les services internes - Utiliser
@RequestHeader("X-User-Email") dans les contrôleurs pour lire l'identité - Créer
FeignConfig avec un RequestInterceptor qui propage X-User-* - Référencer
FeignConfig.class dans @FeignClient(configuration = ...) - Tester de bout en bout : POST /api/orders → Gateway → order-service → product-service, vérifier les headers propagés dans les logs