Architecture
Mettre en place la Clean Architecture dans une application React
Un guide pratique pour structurer une application React avec les principes de la Clean Architecture, et la garder maintenable.
La Clean Architecture en React est une façon de structurer un projet pour que la logique métier reste indépendante de l'interface et des systèmes externes. L'application devient plus simple à faire grandir, à tester et à maintenir.
Le problème qu'elle résout
La plupart des composants React commencent petits. Puis ils grossissent.
Voici un composant que vous avez sûrement déjà écrit :
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((response) => response.json())
.then((data) => {
// Règle métier, enfouie dans un composant
const isPremium = data.plan === "pro" && data.status === "active";
setUser({ ...data, isPremium });
});
}, [userId]);
return <div>{user?.name}</div>;
}
Ce composant fait trois métiers à la fois. Il récupère des données, il applique une règle métier, et il affiche.
Cela crée trois vrais problèmes :
- Vous ne pouvez pas tester la règle premium sans rendre un composant.
- Vous ne pouvez pas réutiliser cette règle ailleurs.
- Le jour où l'API change de forme, vous modifiez des fichiers d'interface.
La Clean Architecture donne à chaque métier sa propre place.
L'idée centrale
Au lieu de tout mélanger (interface, appels API, logique), on sépare l'application en couches.
Chaque couche a une responsabilité claire et ne communique que de manière contrôlée.
La règle de dépendance
C'est la règle qui tient tout l'édifice.
Les dépendances pointent toujours vers l'intérieur. Les couches externes connaissent les couches internes. Les couches internes ne connaissent rien des couches externes.
présentation → domaine ← données
En pratique, cela veut dire :
- La couche domaine n'importe rien de React, ni de votre client API, ni de votre base de données.
- La couche présentation et la couche données dépendent toutes les deux du domaine.
- Le domaine déclare ce dont il a besoin, et la couche données le fournit.
Si vous pouvez supprimer votre dossier d'interface et que votre code de domaine compile toujours, c'est réussi.
Les couches en React
1. La couche présentation
C'est tout ce qui touche à l'interface :
- Les composants React
- Les pages
- L'état d'interface (chargement, erreurs, saisie)
Son rôle est uniquement d'afficher les données et de gérer les interactions.
Elle ne doit PAS contenir de logique métier ni d'appels API directs.
function UserProfile({ userId }: { userId: string }) {
const { user, isLoading } = useUserProfile(userId);
if (isLoading) return <Spinner />;
if (!user) return <EmptyState />;
return <ProfileCard name={user.name} premium={user.isPremium} />;
}
Le composant se lit maintenant comme une description de l'écran. Rien d'autre.
2. La couche domaine
C'est le cœur de votre application.
Elle contient :
- La logique métier
- Les cas d'usage (les actions de l'application)
- Les entités (les modèles centraux)
Exemples de cas d'usage :
- getUserProfile
- createOrder
- calculateTotalPrice
Cette couche ne doit PAS dépendre de React, des APIs ou d'un framework.
Une entité porte les règles qui appartiennent à la donnée elle-même :
export type User = {
id: string;
name: string;
plan: "free" | "pro";
status: "active" | "cancelled";
};
export function isPremium(user: User): boolean {
return user.plan === "pro" && user.status === "active";
}
Un cas d'usage décrit une action de votre application :
export function getUserProfile(users: UserRepository) {
return async (userId: string) => {
const user = await users.findById(userId);
if (!user) return null;
return { ...user, isPremium: isPremium(user) };
};
}
Remarquez ce que le cas d'usage ignore. Il ne sait pas si l'utilisateur vient d'une API, d'un cache ou d'une donnée de test. Il ne connaît que le contrat :
export type UserRepository = {
findById: (id: string) => Promise<User | null>;
};
Ce contrat vit dans le domaine. C'est à la couche données de le respecter.
3. La couche données
Cette couche gère la communication avec les systèmes externes :
- Les requêtes API
- L'accès à la base de données
- Le stockage local
Elle implémente les repositories que le domaine utilise.
Exemple :
- fetchUserFromApi
- l'implémentation de userRepository
export const httpUserRepository: UserRepository = {
async findById(id) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) return null;
const data = await response.json();
// La forme de l'API s'arrête ici. Elle ne fuit jamais dans le domaine.
return {
id: data.user_id,
name: data.full_name,
plan: data.subscription_plan,
status: data.subscription_status,
};
},
};
C'est ici que se fait la traduction. Quand le backend renomme un champ, vous modifiez ce fichier et rien d'autre.
Relier les couches
Les couches se rejoignent dans un adaptateur mince. En React, un hook est un bon endroit :
export function useUserProfile(userId: string) {
const loadProfile = useMemo(
() => getUserProfile(httpUserRepository),
[],
);
const [user, setUser] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
loadProfile(userId).then((profile) => {
if (!cancelled) {
setUser(profile);
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [loadProfile, userId]);
return { user, isLoading };
}
Le hook est le seul endroit qui connaît les deux mondes. Tout ce qui est au-dessus est de l'interface. Tout ce qui est en dessous est de la logique métier.
Exemple de structure de dossiers
Une structure clean architecture typique en React ressemble à ceci :
src/
domain/
entities/
usecases/
repositories/
data/
api/
repositories/
presentation/
pages/
components/
hooks/
state/
Pourquoi les tests deviennent faciles
C'est là que la structure devient rentable.
Pour tester la règle premium, vous n'avez plus besoin d'une bibliothèque de rendu, d'un faux DOM ou d'un fetch simulé. Vous appelez une fonction :
test("un utilisateur pro résilié n'est pas premium", async () => {
const users: UserRepository = {
findById: async () => ({
id: "1",
name: "Ada",
plan: "pro",
status: "cancelled",
}),
};
const profile = await getUserProfile(users)("1");
expect(profile?.isPremium).toBe(false);
});
Tout le repository tient en quatre lignes. Le test s'exécute en quelques millisecondes, et il ne casse que si la règle casse.
Quand ne pas l'utiliser
La Clean Architecture a un coût. Vous écrivez plus de fichiers, et vous naviguez entre eux.
Elle ne vaut généralement pas le coup pour :
- Une landing page ou un site portfolio.
- Un prototype destiné à être jeté.
- Un écran CRUD sans vraie règle métier.
Elle devient rentable quand les règles survivent à l'interface : plusieurs écrans qui partagent la même logique, un backend qui change souvent, une équipe où plusieurs personnes touchent au code.
Vous n'avez pas non plus besoin des trois couches dès le premier jour. Extraire vos règles métier dans de simples fonctions vous donne déjà l'essentiel du bénéfice.
À retenir
- Gardez les règles métier dans de simples fonctions, loin des composants.
- Faites pointer les dépendances vers l'intérieur, vers le domaine.
- Définissez les contrats de repository dans le domaine, implémentez-les dans la couche données.
- Traduisez les formats externes à la frontière, pour qu'un changement d'API reste dans un seul fichier.
- Adoptez les couches progressivement, quand la complexité le demande.