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.tsTout 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.
- Ouvrir
components/,hooks/,reducers/, ça crie : "Je suis une application React !" - Ouvrir
cart/,checkout/,auth/, ça crie : "Je suis un site e-commerce !"
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 :
- Une règle de lint : on interdit tout import qui ne cible pas directement l'
index.tsd'une feature2. - Une structure monorepo : chaque feature devient un package autonome avec ses propres exports dans son
package.json. Le bundler se charge alors de faire respecter les frontières naturellement et on a un joli monolithe modulaire.
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 :
- Charge cognitive : comprendre une feature ne nécessite plus de jongler entre cinq dossiers.
- Parallélisme : deux développeurs peuvent travailler sur deux features sans se marcher dessus.
- 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 :
- Des domaines clairs : l'arborescence de vos dossiers décrit enfin ce que fait votre application (Screaming Architecture), pas les outils qu'elle utilise.
- Des frontières strictes : le fichier
index.tsagit comme un contrat public, garanti par ESLint ou votre monorepo. - Une orchestration centralisée : les features vivent en autarcie, et c'est le Composition Root (la page) qui les assemble via les props.
Pour aller plus loin : Feature-Driven Architecture (dev.to), Orchestrating Scalable Frontends (dev.to) et The Vertical Codebase (tkdodo.eu).
Footnotes
-
https://blog.cleancoder.com/uncle-bob/2011/09/30/Screaming-Architecture.html ↩
-
ESLint avec
no-restricted-importsou viaeslint-plugin-import; Biome avecnoRestrictedImportsvia l'optionpatterns.group(gitignore-style, avec négation!pour whitelister l'index.ts) ↩