Tous les articles

Architecture hexagonale pour de vrai, pas en théorie

24 juin 20266 min de lecture

Le problème de départ, dans un backend Adonis/Lucid classique, ressemble toujours à ça : un service appelle directement Skin.query().where(...), ça marche, ça va vite à écrire, et six mois plus tard on ne peut plus tester une seule règle métier sans monter une base Postgres. Le modèle ORM et la logique de domaine sont soudés. Changer un détail de stockage veut dire toucher le service. Tester une règle de permission veut dire peupler des tables.

Sur le backend de Wakfuli, la plupart des modules métier (items, folders, actions, auth, havenbag, builds, skinator, sublimations) évitent ce piège avec la même découpe répétée : domain/, application/ (les ports), adapters/, services/, controllers/, presenters/, validators/. Ce n'est pas une couche de cérémonie ajoutée après coup, c'est la structure de dossier par défaut d'un module. Le module spells, lui, n'a ni application/ ni adapters/ : il expose des données de référence en lecture seule, sans règle métier à isoler, et personne n'a forcé le moule dessus. C'est un détail qui compte : la discipline ici n'est pas dogmatique, elle s'applique où il y a une frontière à défendre.

Un port et son adapter, pas une promesse abstraite

Prenons le module skinator, qui gère les créations de skins de personnage. skinator/application/ports.ts définit l'interface que le domaine attend d'un dépôt de skins, sans rien savoir de Lucid ni de Postgres :

skinator/application/ports.ts
ts
export interface SkinRepository {
  findByUuidAndUser(uuid: string, userId: number): Promise<Skin | null>;
  findByUuid(uuid: string): Promise<Skin | null>;
  create(data: CreateSkinData): Promise<Skin>;
  mergeAndSave(skin: Skin, data: UpdateSkinData): Promise<Skin>;
  delete(skin: Skin): Promise<void>;
  getUserSkins(userId: number, page: number, limit: number, filters?: SkinFilters): Promise<ModelPaginatorContract<Skin>>;
  getPublicSkins(page: number, limit: number, filters?: SkinFilters): Promise<ModelPaginatorContract<Skin>>;
}

skinator/adapters/skin_repository_adonis.ts implémente cette interface avec Lucid, requêtes SQL comprises :

skinator/adapters/skin_repository_adonis.ts
ts
export class AdonisSkinRepository implements SkinRepository { 
  async findByUuid(uuid: string): Promise<Skin | null> {
    return Skin.query().where('uuid', uuid).first();
  }
 
  async create(data: CreateSkinData): Promise<Skin> {
    return Skin.create({
      userId: data.userId ?? null,
      name: data.name.trim(),
      breedId: data.breedId,
      // ...
    });
  }
  // mergeAndSave, delete, getUserSkins, getPublicSkins suivent le même principe
}
  1. 1

    Contrôleur

    Le contrôleur skins_controller.ts n'importe ni Lucid ni le modèle Skin directement.
  2. 2

    Service

    Il passe par SkinsService, qui expose un SkinPresenter en sortie.
  3. 3

    Port

    La chaîne contrôleur to service to port est respectée de bout en bout, pas seulement sur le papier.

Où ça se paie vraiment, et où ça triche un peu

C'est là que l'honnêteté commence. Le domaine n'est pas aussi pur que la structure de dossiers le laisse penser. skinator/domain/skin.ts, censé être l'entité métier, est en réalité un modèle Lucid : il étend BaseModel, porte des décorateurs @column, connaît le nom de sa table. Ce n'est pas un objet métier découplé de la persistance au sens strict du DDD, c'est un Active Record. Ce que le port isole, ce n'est pas la forme de l'entité, c'est la manière d'y accéder : les requêtes, les filtres, la pagination. La frontière hexagonale ici protège l'accès aux données, pas l'entité elle-même. C'est un compromis assumé, pas une hexagonale de manuel, et ça vaut mieux qu'un port creux qui prétendrait le contraire.

Deuxième nuance, plus subtile : SkinsService déclare son constructeur avec private readonly repo: AdonisSkinRepository, la classe concrète, pas SkinRepository, l'interface. La raison est pragmatique : le conteneur IoC d'AdonisJS résout automatiquement les classes concrètes marquées @inject(), sans binding explicite à enregistrer pour un type abstrait. Le port existe, la classe le respecte, les types partagés (SkinFilters, CreateSkinData) viennent bien de ports.ts.

Le piège

Mais rien n'empêche mécaniquement d'appeler une méthode qui ne serait pas dans l'interface. Remplacer l'adapter pour un test suppose soit d'enregistrer un binding dans le conteneur, soit de fournir un faux objet qui a la même forme. C'est un vrai coût de discipline, pas de mécanique : personne ne vous arrête si vous le contournez.

Ce que ça achète malgré tout

Ce que cette découpe rend possible, concrètement :

Testabilité

Tester la logique de SkinsService (résolution de dossier, génération de token pour un skin anonyme, calcul du breedId) en lui passant un faux repository qui respecte la forme de SkinRepository, sans base de données, sans migration, sans fixture SQL à maintenir.

Lisibilité

Ouvrir services/skins_service.ts donne la logique métier sans bruit SQL, et ouvrir adapters/skin_repository_adonis.ts donne le SQL sans bruit métier. Chaque fichier répond à une seule question.

Le coût, lui, est réel et il ne faut pas le maquiller : neuf modules avec cette découpe, ça veut dire des dizaines de petits fichiers pour des opérations qui, dans un style plus direct, tiendraient dans une seule classe de contrôleur. Sur ce backend, l'ensemble du code applicatif dépasse les lignes réparties sur ces couches, pour un produit de taille moyenne. Une partie de ce volume est structurel : une interface qui répète les signatures de son implémentation, un presenter qui reformate ce que le service vient de calculer. C'est le genre de détail qui fait dire à certains que l'hexagonale, en dessous d'une certaine taille d'équipe ou de domaine, ressemble à du théâtre de dossiers : la forme sans la fonction, des couches qu'on traverse sans qu'elles protègent quoi que ce soit, parce que personne ne les fait respecter dans la durée.

Ici, ce qui évite ce piège, ce n'est pas la présence des dossiers application/ et adapters/, c'est qu'ils sont vraiment traversés dans le bon sens : aucun contrôleur ne touche Lucid, aucun service n'importe un modèle en dehors de son propre module sans passer par un repository, et le module qui n'a pas besoin de la découpe (spells) ne l'a simplement pas reçue. La discipline se vérifie au grep, pas à la lecture du nom des dossiers.

À retenir

Le jugement à en tirer n'est pas "toujours faire de l'hexagonale" ni l'inverse. C'est que ce pattern rembourse son coût quand il existe une vraie frontière à protéger (un stockage qui pourrait changer, une règle métier qu'on veut tester sans dépendance externe, une équipe qui grandit et a besoin que les responsabilités soient localisables), et qu'il devient du poids mort dès qu'on l'applique par réflexe à un module qui n'a ni complexité métier ni besoin de substitution. La compromission sur la pureté du domaine, ici, n'est pas un échec du pattern : c'est le signe qu'il a été appliqué avec un objectif précis (isoler l'accès aux données) plutôt que comme une checklist à cocher.