Aller au contenu

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.env directement 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.