HeavyCookie

Organiser son code par features, pas par type

Au fil de mes missions React, je suis souvent tombé sur cette organisation :

src/
├── components/
├── hooks/
├── utils/
├── types/
└── pages/

C'est lisible au début. Mais à mesure que le projet grossit, components/ finit avec 200 fichiers mélangés, useTheme se retrouve à côté de useTodo, et modifier une feature implique de toucher cinq dossiers différents. Ce qui semblait ordonné devient du bruit.

Le problème : la cohésion

Cette organisation est dite horizontale : on regroupe le code par ce qu'il est (un composant, un hook, un type) plutôt que par ce qu'il fait. Résultat : le code qui change ensemble n'est pas rangé ensemble.

C'est une approche classique, peut-être héritée des frameworks MVC traditionnels type Ruby on Rails où l'on a des dossiers app/controllers, app/models, etc. Dans ce contexte, ça fonctionne (jusqu'à un certain point) parce que l'on sépare les couches techniques du pipeline HTTP (routeur → controller → modèle → vue). Avec React, le paradigme est différent : l'unité de base est le composant, qui couple intimement la vue, l'état et la logique. Séparer ces éléments artificiellement par type de fichier casse cette cohésion naturelle.

Conséquence directe : la découvrabilité s'effondre. TkDodo l'illustre bien dans The Vertical Codebase avec un cas typique — un helper comme widgetQueryOptions, indissociable du composant Widget, atterrit dans utils/ par convention, loin de tout ce qui lui donne du sens. Le développeur (ou un agent IA) fait alors du ping-pong entre quatre dossiers pour reconstituer une feature.

"Agents need mostly the same things humans need: boundaries, constraints, and fast feedback loops." — TkDodo

La solution : les vertical slices

L'idée est simple : regrouper tout ce qui appartient à une même feature dans un seul dossier. Un dossier = une fonctionnalité utilisateur.

features/
├── cart/
│   ├── components/
│   ├── hooks/
│   ├── utils/
│   ├── types/
│   ├── queries.ts
│   └── index.ts
├── checkout/
│   └── index.ts
└── auth/
    └── index.ts

Tout ce qui concerne cart vit dans features/cart/. Pour ajouter un comportement, pour déboguer, pour supprimer — un seul endroit à regarder.

L'architecture par "features" applique la logique du domaine au frontend. Dans le DDD, on cherche à regrouper le code par Bounded Contexts (contextes délimités par le métier). Un dossier features/cart est exactement ça : un contexte métier avec son propre vocabulaire, ses propres règles et ses propres limites.

De plus, comme Robert Martin l'a conceptualisé avec la Screaming Architecture1, les dossiers doivent nous dire ce que fait l'application, pas quel framework elle utilise.

L'entrée publique : index.ts

Le fichier index.ts à la racine de chaque feature joue le rôle d'API publique. Il expose uniquement ce qui doit être consommé depuis l'extérieur : les composants, les helpers publics, les types. Le reste demeure privé à la feature.

// features/cart/index.ts
export { CartSummary } from './components/CartSummary'
export { useCartCount } from './hooks/useCartCount'
export type { CartItem } from './types'

Ça force à réfléchir à ce qui est vraiment public. Une feature qui exporte 40 symboles a probablement besoin d'être découpée.

Sur le papier, la convention est claire, mais dans la vraie vie, on le sait : les humains (et les IA) trichent. Pour faire respecter cette "API publique", deux moyens :

Le code partagé

Tout ne rentre pas dans une feature. Les composants réutilisables entre features (boutons, modales, inputs) méritent leur propre verticale : shared/ ou design-system/.

shared/
├── components/   ← atoms, molecules
├── hooks/        ← useDebounce, useMediaQuery
└── utils/        ← formatDate, cn()

La règle : si un code est utilisé par deux features différentes, il monte dans shared/. Il ne descend jamais dans une feature spécifique.

Pourquoi ça tient dans le temps

L'architecture verticale résout trois problèmes en même temps :

  1. Charge cognitive : comprendre une feature ne nécessite plus de jongler entre cinq dossiers.
  2. Parallélisme : deux développeurs peuvent travailler sur deux features sans se marcher dessus.
  3. Suppression : retirer une feature = supprimer un dossier, sans chasse aux fantômes dans utils/.

L'orchestration : assembler les pièces avec le Composition Root

Mais si nos features sont parfaitement isolées et ne s'importent jamais entre elles, comment le composant Cart transmet-il son montant total à la feature Payment ?

C'est ici qu'intervient le pattern de Composition Root : on délègue l'assemblage, la gestion des états inter-domaines et la communication à une couche supérieure dédiée. En frontend, cette couche d'orchestration est généralement portée par la "Page" (page/ ou app/).

Les features exposent leurs besoins via des props (des événements sortants ou des données entrantes), et c'est l'orchestrateur qui fait la glu :

// app/checkout/page.tsx (L'orchestrateur / Composition Root)
import { useState } from 'react'
import { CartViewer } from '@/features/cart'
import { PaymentForm } from '@/features/payment'
 
export default function CheckoutPage() {
  // L'orchestrateur gère l'état qui lie les deux features
  const [cartTotal, setCartTotal] = useState(0)
  const [paymentSuccessful, setPaymentSuccessful] = useState(false)
 
  if (paymentSuccessful) {
    return <h1>Merci pour votre commande !</h1>
  }
 
  return (
    <div className="grid grid-cols-2 gap-8">
      {/*
        Le Cart calcule le total et le "remonte" à la page.
        Il n'a aucune idée de comment ce total sera payé.
      */}
      <CartViewer
        onTotalCalculated={(total) => setCartTotal(total)}
      />
 
      {/*
        Le Payment reçoit un montant brut.
        Il ignore totalement s'il s'agit d'un panier, d'un don ou d'un abonnement.
      */}
      <PaymentForm
        amount={cartTotal}
        onSuccess={() => setPaymentSuccessful(true)}
      />
    </div>
  )
}

Si demain vous décidez de remplacer le PaymentForm par un système de devis, vous n'aurez pas à toucher une seule ligne de code dans le dossier cart. La feature reste une simple "brique" stupide et isolée, et c'est le Composition Root (la page) qui joue le rôle du chef de chantier pour connecter les features entre elles.

En résumé

Passer à une architecture verticale permet de retrouver de la cohésion et de la sérénité au quotidien :

Pour aller plus loin : Feature-Driven Architecture (dev.to), Orchestrating Scalable Frontends (dev.to) et The Vertical Codebase (tkdodo.eu).

Footnotes

  1. https://blog.cleancoder.com/uncle-bob/2011/09/30/Screaming-Architecture.html

  2. ESLint avec no-restricted-imports ou via eslint-plugin-import ; Biome avec noRestrictedImports via l'option patterns.group (gitignore-style, avec négation ! pour whitelister l'index.ts)

Commentaires