Architecture hexagonale
Introduction
L'architecture hexagonale est une manière d'organiser une application afin de séparer clairement le métier (le domaine) des détails techniques. Cette séparation permet de rendre le code plus modulaire, testable, et évolutif en isolant la logique métier des frameworks, bases de données, ou interfaces utilisateur.
Objectif
Cette page a pour objectif de vous présenter les principes fondamentaux de l'architecture hexagonale, ses avantages, et comment elle s'applique dans un contexte Feelbat.
Le problème
Dans une architecture classique, le domaine métier dépend directement de frameworks comme NestJS, TypeORM, GraphQL, Kafka ou d'autres technologies. Cela crée une chaîne de dépendances en cascade, par exemple :
GraphQL -> Service -> Repository TypeORM -> PostgreSQL
Cette organisation rend le domaine difficile à tester indépendamment, à faire évoluer sans risque, et à réutiliser dans d'autres contextes. En effet, toute modification technique impacte directement la logique métier, ce qui complique la maintenance et l'évolution du projet.
L'idée principale
L'architecture hexagonale propose de placer le domaine au centre de l'application, isolé de toute dépendance technique. Le domaine ne doit dépendre d'aucun framework ni technologie spécifique.
flowchart LR
subgraph Interfaces
Resolver[GraphQL Resolver]
Controller[HTTP Controller]
Consumer[Kafka Consumer]
Command[CLI Command]
end
subgraph Application
UseCase[Application Service]
Port[Repository Port]
EventPort[Event Publisher Port]
end
subgraph Domain
Aggregate[Aggregate]
Rules[Règles métier]
end
subgraph Infrastructure
RepositoryAdapter[TypeORM Repository Adapter]
KafkaAdapter[Kafka Publisher Adapter]
Database[(PostgreSQL)]
end
Resolver --> UseCase
Controller --> UseCase
Consumer --> UseCase
Command --> UseCase
UseCase --> Aggregate
UseCase --> Port
UseCase --> EventPort
RepositoryAdapter --> Port
KafkaAdapter --> EventPort
RepositoryAdapter --> Database
Les technologies évoluent constamment, mais le domaine reste stable. L'objectif de l'architecture hexagonale est de protéger ce domaine des changements techniques, afin d'assurer la pérennité et la robustesse de la logique métier.
Les couches
Interfaces
- Responsabilité : gérer les interactions avec les utilisateurs ou systèmes externes (API REST, GraphQL, interfaces utilisateur, messages Kafka, etc.).
- Contenu : contrôleurs, adaptateurs d'entrée, gestion des requêtes et réponses.
- Ne doit pas contenir : de logique métier ou règles de domaine.
Application
- Responsabilité : orchestrer les cas d'utilisation métier, coordonner les services et les ports.
- Contenu : services applicatifs, cas d'utilisation, gestion des transactions.
- Ne doit pas contenir : de logique métier complexe ni de dépendances techniques.
Domain
- Responsabilité : représenter les règles métier, les entités, les objets de valeur, les invariants.
- Contenu : entités, objets de valeur, règles métier, interfaces (ports) définissant les contrats.
- Ne doit pas contenir : de dépendances à des frameworks ou à des technologies externes.
Infrastructure
- Responsabilité : fournir les implémentations techniques des ports, gérer la communication avec les bases de données, systèmes externes, frameworks.
- Contenu : implémentations des repositories, clients externes, adaptateurs de sortie.
- Ne doit pas contenir : de logique métier.
Une requête de bout en bout
Modifier la fréquence d'un capteur
Voici un exemple concret du parcours d'une requête dans Feelbat, depuis le clic de l'utilisateur jusqu'à la persistance dans la base de données :
Note
Les noms de classes de cet exemple sont illustratifs. Ils servent à décrire le parcours des couches, et non à désigner un fichier précis du code. Le pattern est en revanche bien réel : voir les ports et adapters existants dans Ports & Adapters.
flowchart LR
subgraph Interfaces
Mobile[Application mobile]
Resolver[GraphQL Resolver]
Mapper[Interface Mapper]
end
subgraph Application
Service[UpdateSensorFrequencyService]
end
subgraph Domain
Aggregate[Sensor Aggregate]
Port[SensorRepositoryPort]
end
subgraph Infrastructure
Adapter[TypeOrmSensorRepository]
Db[(PostgreSQL)]
end
Mobile --> Resolver
Resolver --> Mapper
Mapper --> Service
Service --> Aggregate
Service --> Port
Adapter --> Port
Adapter --> Db
| Étape | Rôle |
|---|---|
| Application mobile | Interface utilisateur qui initie la demande de modification de la fréquence |
| GraphQL Resolver | Point d'entrée API qui reçoit la requête et la transmet à l'application |
| Mapper | Convertit les données entrantes en objets métier utilisables par le service |
| UpdateSensorFrequencyService | Service applicatif qui orchestre la modification selon les règles métier |
| Sensor Aggregate | Entité métier qui applique les règles et modifie l'état du capteur |
| SensorRepositoryPort | Interface abstraite définissant les opérations de persistance |
| TypeOrmSensorRepository | Implémentation technique qui sauvegarde les données dans PostgreSQL via TypeORM |
| PostgreSQL | Base de données où les données sont stockées |
Chaque couche possède une responsabilité unique et aucune ne connaît les détails des autres. Cette séparation assure modularité, testabilité et facilité d'évolution.
Pourquoi inverser les dépendances ?
Le principe d'inversion des dépendances (Dependency Inversion Principle) consiste à faire dépendre les modules de haut niveau (le domaine et l'application) d'abstractions (interfaces ou ports), et non de modules de bas niveau (implémentations techniques).
Exemple Feelbat avec un service Sensors qui sauvegarde un capteur :
Mauvaise approche
ApplicationService -> TypeOrmSensorRepository
Ici, le service applicatif dépend directement de l'implémentation TypeORM, ce qui crée un couplage fort.
Bonne approche
ApplicationService -> SensorRepositoryPort <- TypeOrmSensorRepository
Le service dépend d'un port (interface) qui définit le contrat métier. L'implémentation technique est injectée derrière ce port.
graph LR
ApplicationService -->|dépend de| SensorRepositoryPort
TypeOrmSensorRepository -->|implémente| SensorRepositoryPort
Pourquoi utiliser des ports ?
Le domaine n'a pas besoin de connaître la marque de la prise électrique ; il a seulement besoin d'électricité. De la même manière, un service applicatif ne veut pas savoir si les données sont enregistrées dans PostgreSQL, MongoDB ou ailleurs : il veut simplement qu'elles soient sauvegardées.
Autrement dit, un service applicatif n'exprime jamais un besoin technique.
Il n'a pas besoin de PostgreSQL, de TypeORM ou de MongoDB. Il exprime uniquement un besoin métier.
Mauvais exemple
new TypeOrmSensorRepository().save(sensor);
Le service décide lui-même quelle technologie utiliser.
Bon exemple
sensorRepository.save(sensor);
Le service demande simplement qu'un capteur soit sauvegardé. La technologie utilisée est un détail de l'infrastructure.
Un port représente un contrat métier, pas une implémentation. L'application ne s'intéresse pas à comment un capteur est sauvegardé, mais seulement à ce qu'un capteur soit sauvegardé.
Les bénéfices sont multiples :
- Découplage : l'application est indépendante des détails techniques.
- Testabilité : il est facile de substituer des implémentations par des mocks ou stubs.
- Changement d'implémentation : on peut remplacer une technologie sans modifier le domaine ni l'application.
- Indépendance des frameworks : le cœur métier reste pur et stable.
Exemple concret
Aujourd'hui, le service sensors utilise TypeORM et PostgreSQL.
Demain, l'équipe décide de migrer vers une autre technologie de persistance.
Seul l'adapter d'infrastructure change. Le domaine, les services applicatifs, les tests et les interfaces restent identiques.
Ce principe a également facilité l'extraction progressive de la gestion des capteurs hors de legacy-rs-manager vers le futur service sensors.
graph LR
ApplicationService --> SensorRepositoryPort
TypeOrmSensorRepository -. remplacé par .-> MongoDbSensorRepository
MongoDbSensorRepository --> SensorRepositoryPort
Le domaine et l'application restent inchangés, assurant une grande flexibilité.
Les erreurs classiques
- Mettre de la logique métier dans un resolver GraphQL.
- Manipuler directement TypeORM depuis un service applicatif.
- Utiliser
process.envdirectement dans le domaine. - Faire dépendre le domaine d'un framework.
- Faire communiquer deux domaines en partageant leur base de données.
Bonnes pratiques
Conventions Feelbat à respecter :
- Le domaine ne dépend jamais d'un framework.
- Les services applicatifs dépendent uniquement de ports.
- Les implémentations techniques vivent dans la couche infrastructure.
- Les interfaces ne contiennent pas de logique métier.
- Un changement technique ne doit pas impacter le domaine.
- Les services applicatifs expriment uniquement des besoins métier ; ils ne choisissent jamais une technologie d'implémentation.
Pour aller plus loin
La règle d'or
Le domaine ne doit jamais savoir :
- où les données sont stockées ;
- comment elles sont transportées ;
- quel framework est utilisé ;
- quel protocole est utilisé.
Il exprime uniquement son besoin métier.
Tout le reste appartient à l'infrastructure.
À retenir
- Le domaine est au centre et indépendant des détails techniques.
- Les dépendances vont toujours vers le domaine.
- Les ports définissent des contrats métier abstraits.
- L'infrastructure fournit les implémentations techniques.
- Cette organisation facilite la testabilité, la maintenance et l'évolution.