Stratégies de Migration vers l’Architecture Hexagonale
Cette page présente les stratégies de migration pour transformer une application monolithique existante vers une architecture hexagonale.
Pourquoi Migrer ?
Migrer vers une architecture hexagonale apporte de nombreux bénéfices pour une application existante.
❌ Architecture Monolithique
Problèmes typiques d’un monolithe
@Controller("/api/users")
@RequiredArgsConstructor
public class UserController {
private final DataSource dataSource; // Couplage direct à la BDD
private final RestTemplate restTemplate; // Couplage à l'infra HTTP
@Get("/{id}")
public User getUser(Long id) {
// Logique métier mélangée avec infrastructure
String sql = "SELECT * FROM users WHERE id = ?";
User user = jdbcTemplate.queryForObject(sql, new UserRowMapper(), id);
// Appel API externe direct dans le controller
String response = restTemplate.getForObject("https://api.example.com/users/" + id, String.class);
// Validation métier dans le controller
if (user.getAge() < 18) {
throw new IllegalArgumentException("User must be 18+");
}
return user;
}
}Problèmes :
- ❌ Couplage fort entre couches (Controller → BDD → API externe)
- ❌ Logique métier dispersée (validation, règles)
- ❌ Impossible de tester sans base de données réelle
- ❌ Difficile de changer de technologie (JdbcTemplate → JPA)
- ❌ Duplication de code entre controllers
Migration ≠ Réécriture complète. Une migration progressive permet de réduire les risques et de livrer de la valeur en continu.
Stratégies de Migration
Il existe 3 stratégies principales pour migrer vers l’architecture hexagonale.
Big Bang
Big Bang (Réécriture complète)
Principe : réécrire toute l’application d’un coup.
Ancien système (Monolithe)
↓
Réécriture complète (3-6 mois)
↓
Nouveau système (Hexagonal)Avantages :
- ✅ Architecture propre dès le départ
- ✅ Pas de compromis techniques
- ✅ Suppression de la dette technique
Inconvénients :
- ❌ Risque élevé (big bang deployment)
- ❌ Pas de valeur livrée pendant la réécriture
- ❌ Scope creep (ajout de features pendant la migration)
- ❌ Nécessite un freeze des nouvelles fonctionnalités
Quand l’utiliser ?
- Application très petite (<5000 lignes)
- Code legacy irrécupérable
- Équipe disponible à 100% pour la migration
Attention : le Big Bang échoue dans 70% des cas pour les applications de taille moyenne/grande.
Étapes de Migration (Strangler Pattern)
Voici les étapes concrètes pour migrer progressivement vers l’architecture hexagonale.
Identifier les Bounded Contexts
Analysez votre application pour identifier les domaines métier (bounded contexts).
// Exemple : Application e-commerce monolithique
MonolithController
├─ User Management (users, authentication)
├─ Product Catalog (products, categories)
├─ Order Management (orders, cart)
└─ Payment Processing (payments, invoices)Techniques :
- Analyser les entités métier (User, Order, Product)
- Identifier les agrégats (Order + OrderItems)
- Chercher les frontières naturelles (gestion des users vs gestion des commandes)
Bounded contexts identifiés :
- User Context : gestion des utilisateurs
- Catalog Context : gestion du catalogue produits
- Order Context : gestion des commandes
- Payment Context : gestion des paiements
Astuce : commencez par migrer le bounded context le plus isolé (moins de dépendances).
Créer la structure hexagonale
Créez la structure de dossiers pour le bounded context choisi.
- OldUserController.java
Extraire le Domain Layer
Identifiez les entités métier et les règles de validation dans l’ancien code.
❌ Avant (Monolithe)
// OldUserController.java
@Controller("/api/users")
public class OldUserController {
@Post("/")
public User createUser(@Body CreateUserDto dto) {
// Validation inline
if (dto.getEmail() == null || !dto.getEmail().contains("@")) {
throw new IllegalArgumentException("Invalid email");
}
if (dto.getAge() < 18) {
throw new IllegalArgumentException("User must be 18+");
}
// Création avec setters
User user = new User();
user.setEmail(dto.getEmail());
user.setName(dto.getName());
user.setAge(dto.getAge());
return userRepository.save(user);
}
}Créer les Use Cases
Extraire la logique métier dans des Use Cases dédiés.
// application/ports/in/CreateUserUseCase.java
package com.example.user.application.ports.in;
public interface CreateUserUseCase {
User execute(CreateUserCommand command);
}
// application/ports/in/CreateUserCommand.java
public record CreateUserCommand(
String email,
String name,
int age
) {}
// application/usecases/CreateUserUseCaseImpl.java
package com.example.user.application.usecases;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@Singleton
@RequiredArgsConstructor
public class CreateUserUseCaseImpl implements CreateUserUseCase {
private final UserRepository userRepository;
@Override
public User execute(CreateUserCommand command) {
// Créer l'entité (validation automatique)
User user = new User(
null,
command.email(),
command.name(),
command.age()
);
// Sauvegarder via le port OUT
return userRepository.save(user);
}
}Créer les Adapters
Implémenter les adapters d’entrée (Controller) et de sortie (Repository).
Adapter IN (Controller)
// infrastructure/adapters/in/rest/UserController.java
package com.example.user.infrastructure.adapters.in.rest;
import io.micronaut.http.annotation.*;
import jakarta.inject.Inject;
import lombok.RequiredArgsConstructor;
@Controller("/api/v2/users") // Nouvelle route
@RequiredArgsConstructor
public class UserController {
private final CreateUserUseCase createUserUseCase;
private final GetUserUseCase getUserUseCase;
@Post("/")
public UserResponse createUser(@Body CreateUserRequest request) {
// Mapper DTO → Command
CreateUserCommand command = new CreateUserCommand(
request.email(),
request.name(),
request.age()
);
// Appeler le Use Case
User user = createUserUseCase.execute(command);
// Mapper Domain → DTO
return new UserResponse(
user.getId(),
user.getEmail(),
user.getName()
);
}
@Get("/{id}")
public UserResponse getUser(Long id) {
User user = getUserUseCase.execute(id);
return new UserResponse(user.getId(), user.getEmail(), user.getName());
}
}
// DTOs
public record CreateUserRequest(String email, String name, int age) {}
public record UserResponse(Long id, String email, String name) {}Router les requêtes
Créer un router pour diriger le trafic vers l’ancien ou le nouveau système.
// infrastructure/routing/UserRoutingController.java
package com.example.routing;
import io.micronaut.http.annotation.*;
import jakarta.inject.Inject;
@Controller("/api/users")
public class UserRoutingController {
private final UserController newController; // Nouveau système
private final OldUserController oldController; // Ancien système
private final FeatureToggleService featureToggle;
@Get("/{id}")
public UserResponse getUser(Long id) {
if (featureToggle.isEnabled("hexagonal-users")) {
// Route vers nouveau système
return newController.getUser(id);
} else {
// Route vers ancien système
return oldController.getUser(id);
}
}
@Post("/")
public UserResponse createUser(@Body CreateUserRequest request) {
if (featureToggle.isEnabled("hexagonal-users")) {
return newController.createUser(request);
} else {
return oldController.createUser(request);
}
}
}Feature Toggle :
# application.yml
feature-toggles:
hexagonal-users: true # Activer progressivement (10%, 50%, 100%)Tester et Valider
Écrire des tests pour valider la migration.
@MicronautTest
class CreateUserUseCaseTest {
@Inject
CreateUserUseCase useCase;
@MockBean(UserRepository.class)
UserRepository userRepository() {
return mock(UserRepository.class);
}
@Test
void shouldCreateValidUser() {
// Given
CreateUserCommand command = new CreateUserCommand(
"john.doe@example.com",
"John Doe",
25
);
User savedUser = new User(1L, "john.doe@example.com", "John Doe", 25);
when(userRepository.save(any())).thenReturn(savedUser);
// When
User result = useCase.execute(command);
// Then
assertThat(result.getEmail()).isEqualTo("john.doe@example.com");
verify(userRepository).save(any(User.class));
}
@Test
void shouldRejectInvalidEmail() {
// Given
CreateUserCommand command = new CreateUserCommand(
"invalid-email",
"John Doe",
25
);
// When/Then
assertThatThrownBy(() -> useCase.execute(command))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid email");
}
}Basculer progressivement
Basculer le trafic progressivement avec un feature toggle.
Phase 1 : Dark launch (0% traffic)
feature-toggles:
hexagonal-users: false # Ancien système uniquementPhase 2 : Canary release (10% traffic)
feature-toggles:
hexagonal-users: true
hexagonal-users-percentage: 10 # 10% des utilisateursPhase 3 : Ramp up (50% traffic)
feature-toggles:
hexagonal-users-percentage: 50Phase 4 : Full deployment (100% traffic)
feature-toggles:
hexagonal-users: true
hexagonal-users-percentage: 100Phase 5 : Supprimer l’ancien code
// Supprimer OldUserController.java
// Supprimer UserRoutingController.java
// Renommer /api/v2/users → /api/usersMigration réussie : vous avez migré un bounded context complet vers l’architecture hexagonale ! 🎉
Exemple Complet : Migration d’un Controller
Voici un exemple concret de migration d’un controller monolithique.
❌ Avant (Monolithe)
Controller Monolithique
@Controller("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final DataSource dataSource;
private final RestTemplate restTemplate;
private final EmailService emailService;
@Post("/")
public OrderResponse createOrder(@Body CreateOrderDto dto) {
// Validation inline
if (dto.getUserId() == null) {
throw new IllegalArgumentException("User ID required");
}
if (dto.getItems().isEmpty()) {
throw new IllegalArgumentException("Order must have items");
}
// Vérifier que l'utilisateur existe (appel BDD direct)
String userSql = "SELECT * FROM users WHERE id = ?";
User user = jdbcTemplate.queryForObject(userSql, new UserRowMapper(), dto.getUserId());
if (user == null) {
throw new IllegalArgumentException("User not found");
}
// Vérifier le stock (appel API direct)
for (OrderItemDto item : dto.getItems()) {
String stockUrl = "https://inventory.example.com/products/" + item.getProductId() + "/stock";
Integer stock = restTemplate.getForObject(stockUrl, Integer.class);
if (stock < item.getQuantity()) {
throw new IllegalArgumentException("Insufficient stock for product " + item.getProductId());
}
}
// Calculer le total
BigDecimal total = dto.getItems().stream()
.map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
// Sauvegarder la commande
String orderSql = "INSERT INTO orders (user_id, total, status) VALUES (?, ?, ?)";
jdbcTemplate.update(orderSql, dto.getUserId(), total, "PENDING");
// Envoyer email de confirmation
emailService.sendOrderConfirmation(user.getEmail(), total);
return new OrderResponse(1L, total, "PENDING");
}
}Problèmes :
- ❌ 150+ lignes dans un seul controller
- ❌ Logique métier mélangée avec infrastructure
- ❌ Impossible de tester sans BDD et API externe
- ❌ Couplage fort (JDBC, RestTemplate, EmailService)
Pièges Courants et Solutions
Attention : voici les pièges les plus fréquents lors d’une migration.
Piège 1
Piège 1 : Couplage résiduel
Problème : garder des dépendances directes à l’infrastructure dans les Use Cases.
// ❌ MAUVAIS : dépendance directe à JPA
@Singleton
public class CreateUserUseCase {
@PersistenceContext
private EntityManager entityManager; // ❌ Couplage à JPA
public User execute(CreateUserCommand command) {
UserEntity entity = new UserEntity();
entityManager.persist(entity); // ❌ Utilisation directe de JPA
return entity;
}
}Solution : utiliser un port (interface).
// ✅ BON : dépendance à une abstraction (port)
@Singleton
@RequiredArgsConstructor
public class CreateUserUseCase {
private final UserRepository userRepository; // ✅ Interface (port)
public User execute(CreateUserCommand command) {
User user = new User(command.email(), command.name());
return userRepository.save(user); // ✅ Appel via le port
}
}Checklist de Migration
Utilisez cette checklist pour suivre votre migration.
Phase 1 : Préparation
- Identifier les bounded contexts
- Choisir le bounded context à migrer (le plus isolé)
- Écrire les tests de l’ancien système (tests de caractérisation)
- Créer la structure de dossiers hexagonale
- Configurer le routing/feature toggle
Phase 2 : Domain Layer
- Extraire les entités métier
- Déplacer les règles de validation dans les constructeurs
- Déplacer les calculs métier dans les entités
- Créer les Value Objects (Email, Money, etc.)
- Écrire les tests unitaires du domain
Phase 3 : Application Layer
- Définir les ports IN (Use Cases)
- Définir les ports OUT (Repositories, Services)
- Implémenter les Use Cases
- Écrire les tests unitaires des Use Cases (avec mocks)
Phase 4 : Infrastructure Layer
- Créer l’adapter d’entrée (Controller, GraphQL Resolver)
- Créer les adapters de sortie (Repository, API Client)
- Implémenter les mappers DTO ↔ Domain ↔ Entity
- Écrire les tests d’intégration
Phase 5 : Déploiement
- Déployer avec feature toggle à 0%
- Tests manuels sur l’environnement de staging
- Activer progressivement (10%, 25%, 50%, 100%)
- Monitorer les métriques (latence, erreurs)
- Comparer le comportement ancien vs nouveau (logs, métriques)
Phase 6 : Nettoyage
- Supprimer l’ancien code (après 100% du trafic sur le nouveau)
- Supprimer le routing/feature toggle
- Renommer les routes
/api/v2→/api - Mettre à jour la documentation
Bravo ! Vous avez migré un bounded context vers l’architecture hexagonale. Répétez pour les autres contextes.
Outils et Techniques
Feature Toggles
# application.yml
micronaut:
application:
name: my-app
feature-toggles:
hexagonal-users:
enabled: true
rollout-percentage: 50 # 50% des utilisateurs@Singleton
public class FeatureToggleService {
@Value("${feature-toggles.hexagonal-users.enabled}")
private boolean hexagonalUsersEnabled;
@Value("${feature-toggles.hexagonal-users.rollout-percentage:0}")
private int rolloutPercentage;
public boolean isEnabled(String feature) {
if ("hexagonal-users".equals(feature)) {
if (!hexagonalUsersEnabled) return false;
// Déploiement progressif basé sur hash du user ID
return ThreadLocalRandom.current().nextInt(100) < rolloutPercentage;
}
return false;
}
}Tests de Comparaison (Shadow Testing)
@Controller("/api/users")
@RequiredArgsConstructor
public class UserShadowController {
private final OldUserController oldController;
private final UserController newController;
private final MeterRegistry meterRegistry;
@Get("/{id}")
public UserResponse getUser(Long id) {
UserResponse oldResult = oldController.getUser(id);
try {
UserResponse newResult = newController.getUser(id);
// Comparer les résultats
if (!oldResult.equals(newResult)) {
meterRegistry.counter("migration.mismatch", "endpoint", "getUser").increment();
log.warn("Mismatch detected for user {}: old={}, new={}",
id, oldResult, newResult);
}
} catch (Exception e) {
meterRegistry.counter("migration.error", "endpoint", "getUser").increment();
log.error("Error in new system for user {}: {}", id, e.getMessage());
}
// Toujours retourner le résultat de l'ancien système
return oldResult;
}
}Métriques de Migration
@Singleton
@RequiredArgsConstructor
public class MigrationMetrics {
private final MeterRegistry meterRegistry;
public void recordMigrationCall(String endpoint, String system, boolean success) {
meterRegistry.counter("migration.calls",
"endpoint", endpoint,
"system", system,
"status", success ? "success" : "error"
).increment();
}
public void recordLatency(String endpoint, String system, long durationMs) {
meterRegistry.timer("migration.latency",
"endpoint", endpoint,
"system", system
).record(Duration.ofMillis(durationMs));
}
}Estimation du Temps de Migration
| Taille du bounded context | Temps estimé |
|---|---|
| Petit (1-2 endpoints, 1 entité) | 1-2 jours |
| Moyen (5-10 endpoints, 3-5 entités) | 1-2 semaines |
| Grand (20+ endpoints, 10+ entités) | 1-2 mois |
Facteurs d’ajustement :
- +50% si pas de tests existants
- +30% si logique métier très complexe
- -20% si équipe expérimentée en architecture hexagonale
Références
- Strangler Fig Pattern
- Feature Toggles
- Working Effectively with Legacy Code (Michael Feathers)
- Domain-Driven Design (Eric Evans)
Félicitations ! Vous avez toutes les clés pour migrer votre application vers l’architecture hexagonale. 🎉
Section suivante : Consultez les autres guides avancés pour approfondir vos connaissances.