Architecture hexagonale en C++ : comment arrêter d'avoir peur des changements et aimer les tests
\n\nAvez-vous déjà réécrit un module de gestion de base de données, pour ensuite réaliser que vous deviez modifier la moitié de la logique métier ? Ou avez-vous essayé de couvrir de tests unitaires une classe qui entraîne trois tonnes de dépendances du monde extérieur ? Si oui — bienvenue au club. Heureusement, nous avons un outil qui résout ces problèmes une fois pour toutes : l'architecture hexagonale (également appelée architecture Ports & Adapters).
\n\nDans cet article, nous allons voir comment appliquer ce puissant concept en C++. Vous ne verrez pas de schémas abstraits, mais du code réel que vous pouvez utiliser dès maintenant. Et oui, nous le ferons sans fanatisme — de manière à ce que le projet legacy n'explose pas et que le nouveau code reste propre.
\n\nQu'est-ce que l'architecture hexagonale (et quel est le rapport avec l'hexagone) ?
\n\nImaginez le noyau de votre application — c'est la logique métier pure, qui ne connaît ni les bases de données, ni les API REST, ni l'interface graphique. Autour de ce noyau se trouvent les ports (interfaces), et encore plus loin, les adaptateurs (implémentations concrètes). Visuellement, cela ressemble à un hexagone, d'où le nom.
\n\nL'idée clé : les dépendances sont dirigées vers l'intérieur. Le monde extérieur dépend du noyau, et non l'inverse. Cela permet de changer la base de données, le protocole de communication ou l'interface utilisateur sans modifier la logique métier. Cela semble être un rêve ? Voyons comment le réaliser en C++.
\n\nLe problème classique : un couplage fort
\n\nVoici à quoi ressemble souvent un code C++ typique :
\n\nclass OrderService {\npublic:\n void createOrder(const OrderData& data) {\n // Logique métier\n if (data.items.empty()) throw std::runtime_error("Commande vide");\n\n // Et voici le problème — dépendance directe à la base de données\n Database db("localhost", 5432);\n db.connect();\n db.saveOrder(data);\n db.disconnect();\n \n // Et aussi au service email\n EmailSender sender("smtp.example.com");\n sender.sendInvoice(data);\n }\n};\n\nQu'est-ce qui ne va pas ici ? Tout. Vous ne pouvez pas tester createOrder sans une vraie base de données et un serveur de messagerie. Si demain vous décidez de passer de PostgreSQL à MongoDB — vous devrez réécrire OrderService. Et si le service email devient asynchrone via une file d'attente — encore des modifications dans la logique métier.
Résoudre le problème avec l'architecture hexagonale
\n\nÉtape 1 : Définir les ports (interfaces)
\n\nUn port est un contrat qui définit ce dont le noyau a besoin du monde extérieur, mais pas comment cela est implémenté.
\n\n// port/OrderRepository.h\nclass OrderRepository {\npublic:\n virtual ~OrderRepository() = default;\n virtual void save(const Order& order) = 0;\n virtual std::optional<Order> findById(const std::string& id) = 0;\n};\n\n// port/NotificationService.h\nclass NotificationService {\npublic:\n virtual ~NotificationService() = default;\n virtual void sendInvoice(const Order& order) = 0;\n};\n\nRemarquez : ce sont des classes abstraites pures. Aucune dépendance aux frameworks, bibliothèques ou services externes. Uniquement des entités métier.
\n\nÉtape 2 : Écrire le noyau (logique métier)
\n\nMaintenant, notre service ne dépend que des interfaces, et non des implémentations concrètes :
\n\n// core/OrderService.h\nclass OrderService {\npublic:\n OrderService(OrderRepository& repo, NotificationService& notifier)\n : repository_(repo), notifier_(notifier) {}\n\n void createOrder(const OrderData& data) {\n // Validation — logique métier pure\n if (data.items.empty()) {\n throw std::invalid_argument("La commande ne peut pas être vide");\n }\n\n Order order = Order::create(data);\n repository_.save(order);\n notifier_.sendInvoice(order);\n }\n\nprivate:\n OrderRepository& repository_;\n NotificationServ