Une bonne structure de dossiers ne sert à rien si n’importe qui peut tout importer de n’importe où. Les frontières (boundaries) sont les règles qui disent qui a le droit de dépendre de qui. Elles rendent l’application facile à modifier : quand vous savez que shared ne dépend d’aucune feature, vous savez que vous pouvez le changer sans casser le domaine.
La matrice des dépendances
| importe depuis → fichier de ↓ | app (config, routes) | core | shared | features/products | features/cart |
|---|---|---|---|---|---|
| app (config, routes) | |||||
| core | |||||
| shared | |||||
| features/products | |||||
| features/cart |
features → core, shared · core → shared · shared → rien d’autre. Jamais dans l’autre sens, et jamais feature → feature directement.
Alias de chemins
{
"compilerOptions": {
"paths": {
"@core/*": ["./src/app/core/*"],
"@shared/*": ["./src/app/shared/*"],
"@features/*": ["./src/app/features/*"]
}
}
}
// (exactement la configuration utilisée par ce site)Barrel files (index.ts)
// features/users/index.ts — l’API PUBLIQUE de la feature (le seul barrel)
export { UserAvatar } from './ui/user-avatar';
export { UsersStore } from './data-access/users-store';
export type { User, Role } from './models/user';
// shared/ui/index.ts — un raccourci pour importer plusieurs composants d’UI d’un coup
export { Button } from './button';
export { Card } from './card';
export const PAGE_KIT = [Button, Card] as const;Vérification automatique
Les règles écrites uniquement dans la documentation sont violées dès la première semaine. Mettez-les dans le lint :
// eslint.config.mjs — règles de frontières vérifiées automatiquement
export default [
{
files: ['src/app/shared/**/*.ts'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{ group: ['@features/*'], message: 'shared ne doit pas dépendre des features.' },
{ group: ['@core/*'], message: 'shared ne dépend pas de core — il reçoit ses données par des Inputs.' },
],
}],
},
},
{
files: ['src/app/features/**/*.ts'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{ group: ['@features/*/*'], message: 'Importez une autre feature uniquement via son API publique (@features/x).' },
],
}],
},
},
];
// Alternatives : eslint-plugin-boundaries, Nx @nx/enforce-module-boundaries (with tags)Comment deux features communiquent-elles sans s’importer mutuellement ?
- Par l’URL : le produit envoie l’utilisateur vers
/cart?add=42. - Par core : un Store global (
CartStoredanscore) que les deux utilisent. - Par shared : un type commun ou un composant générique.
- Par l’API publique : si A a vraiment besoin d’un composant de B, B l’exporte explicitement dans
index.ts.
Dépendances circulaires : comment les casser
Un cycle apparaît quand A importe depuis B et que B, directement ou via d’autres, importe depuis A. Les symptômes sont étranges : une classe vaut undefined au démarrage, les tests ne passent que dans un autre ordre, le build ralentit. Trois sorties, dans l’ordre où elles valent la peine :
- Descendre la partie commune. Si les deux ont besoin du même type ou de la même fonction, déplacez-le dans
sharedoucore, et le cycle disparaît. - Inverser la dépendance. Le niveau bas déclare un contrat (un
InjectionTokenou une classe abstraite), le niveau haut l’implémente et le fournit. Le niveau bas n’importe plus rien d’en haut. - Communiquer sans import. Par l’URL, un store dans
coreou un événement, comme dans la section ci-dessus.
// Avant : core/notifier importait depuis features/orders pour le lien → cycle. Après : core déclare un token.
// core/notifications/notifier.ts
export const ORDER_LINK = new InjectionToken<(id: string) => string>('ORDER_LINK');
@Service()
export class Notifier {
private readonly link = inject(ORDER_LINK); // n’importe plus rien depuis features
orderShipped(id: string) {
this.show(`La commande a été expédiée`, this.link(id));
}
}
// features/orders/orders.providers.ts — la feature fournit l’implémentation
export const provideOrders = () => [
{ provide: ORDER_LINK, useValue: (id: string) => `/orders/${id}` },
];Des frontières aussi pour les bibliothèques externes
La même idée s’applique aux paquets de node_modules. Si 40 composants importent directement une bibliothèque de graphiques ou de dates, la remplacer signifie modifier 40 fichiers. Placez la bibliothèque derrière un petit adaptateur à vous, et interdisez l’import direct dans le lint.
// shared/charts/line-chart.ts — le seul fichier qui importe la bibliothèque
import { Chart } from 'some-chart-lib';
@Component({ selector: 'app-line-chart', template: '<canvas #c></canvas>' })
export class LineChart {
readonly points = input.required<readonly { x: string; y: number }[]>();
// le reste de l’app ne voit que <app-line-chart [points]="...">
}Pour imposer les mêmes règles entre bibliothèques, voir Monorepo, bibliothèques et frontières ; les zones elles-mêmes sont décrites dans Shared vs feature.
Pièges fréquents
- Un barrel dans chaque dossier → dépendances circulaires et builds lents.
- Un alias qui masque les violations :
@features/cart/data-access/cart-storeimporté depuis une autre feature reste une violation — c’est pourquoi il existe une règle de lint. - « Juste cette fois » : une seule exception devient un pattern. Déplacez le code au bon endroit.
Aide-mémoire
features → core, shared autorisé
core → shared
shared → rien de l’application
feature → feature pas directement : par l’URL, un store dans core ou une API publique
cycle ? 1) descendre la partie commune 2) inverser avec un token 3) communiquer sans import
bibliothèque externe ? un adaptateur dans shared + no-restricted-imports
barrel : un seul, à la racine de la feature