Skip to Content
06 AdvancedStratégies de Migration vers l'Architecture Hexagonale

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.

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 (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 :

  1. User Context : gestion des utilisateurs
  2. Catalog Context : gestion du catalogue produits
  3. Order Context : gestion des commandes
  4. 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.

// 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).

// 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 uniquement

Phase 2 : Canary release (10% traffic)

feature-toggles: hexagonal-users: true hexagonal-users-percentage: 10 # 10% des utilisateurs

Phase 3 : Ramp up (50% traffic)

feature-toggles: hexagonal-users-percentage: 50

Phase 4 : Full deployment (100% traffic)

feature-toggles: hexagonal-users: true hexagonal-users-percentage: 100

Phase 5 : Supprimer l’ancien code

// Supprimer OldUserController.java // Supprimer UserRoutingController.java // Renommer /api/v2/users → /api/users

Migration 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.

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 : 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 contextTemps 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


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.

Last updated on