- Angular
- TypeScript
- Architecture
- Frontend
Ma méthode pour un front-end Angular qui reste maintenable
Découpage par domaine, composants standalone, signaux contre RxJS, intercepteurs et détection de changement : les décisions que je prends au démarrage d'un projet Angular.
Angular impose peu de choses et en permet beaucoup. C'est confortable au premier sprint et pénible au douzième, quand trois développeurs ont résolu le même problème de trois façons. Voici les décisions que je fige dès le départ, affinées sur Keydesk puis sur les applications métier qui ont suivi.
Découper par domaine, jamais par type technique
L'arborescence par type (components/, services/, models/) paraît logique et devient vite inutilisable : toucher à une fonctionnalité oblige à ouvrir quatre dossiers éloignés.
Je découpe par domaine métier :
src/app/
├── core/ # singletons : intercepteurs, garde d'auth, config
├── shared/ # composants réellement réutilisés partout
└── features/
├── reservations/
│ ├── pages/ # routés, branchés aux données
│ ├── ui/ # présentation pure, aucune dépendance HTTP
│ ├── data/ # client HTTP typé + store du domaine
│ └── reservations.routes.ts
└── facturation/La règle qui rend ce découpage réel : un dossier features/ n'importe jamais depuis un autre dossier features/. S'il le faut, c'est que le code appartient à shared/. Une règle ESLint de restriction d'import suffit à la faire respecter sans discussion en revue.
Composants standalone et routes paresseuses
Les NgModule n'apportent plus rien sur un projet neuf. Les composants standalone réduisent le bruit et rendent le chargement différé trivial :
export const reservationRoutes: Routes = [
{
path: "",
loadComponent: () => import("./pages/reservation-list.page").then((m) => m.ReservationListPage),
},
{
path: ":id",
loadComponent: () => import("./pages/reservation-detail.page").then((m) => m.ReservationDetailPage),
resolve: { reservation: reservationResolver },
},
];Chaque fonctionnalité devient un bundle séparé. Sur une application de back-office qui compte trente écrans, c'est la différence entre un premier chargement de 1,8 Mo et un de 300 Ko.
Signaux pour l'état, RxJS pour les flux
C'est la question qui divise le plus les équipes. Mon critère est simple :
- Un signal dès qu'il s'agit d'une valeur que le template lit : le filtre actif, la ligne sélectionnée, un état de chargement.
- RxJS dès qu'il s'agit d'un flux dans le temps : saisie clavier à débouncer, WebSocket, appels à annuler, séquences à composer.
@Injectable({ providedIn: "root" })
export class ReservationStore {
private readonly api = inject(ReservationApi);
readonly filter = signal<ReservationFilter>({ status: "all" });
readonly reservations = signal<Reservation[]>([]);
readonly loading = signal(false);
// Dérivé : recalculé automatiquement, jamais stocké en double
readonly pendingCount = computed(
() => this.reservations().filter((r) => r.status === "pending").length,
);
async load() {
this.loading.set(true);
try {
this.reservations.set(await firstValueFrom(this.api.list(this.filter())));
} finally {
this.loading.set(false);
}
}
}Le piège à éviter : stocker dans un signal une valeur qu'on peut dériver. pendingCount ne doit jamais être un signal mis à jour à la main : c'est la garantie d'une désynchronisation silencieuse au troisième endroit qui modifie la liste.
Les intercepteurs portent tout ce qui est transversal
Authentification, renouvellement de jeton, gestion d'erreur, indicateur de chargement global : rien de tout cela n'a sa place dans un composant.
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(TokenStore).access();
const authorized = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next(authorized).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) return inject(AuthService).refreshAndRetry(authorized, next);
return throwError(() => error);
}),
);
};Conséquence directe : aucun composant ne manipule de jeton, et le jour où l'en-tête change, un seul fichier bouge.
Typer les réponses jusqu'au bout
HttpClient accepte un générique, mais rien ne garantit que le serveur respecte le contrat. Sur les endpoints critiques (paiement, facturation), je valide la réponse plutôt que de la caster :
list(filter: ReservationFilter): Observable<Reservation[]> {
return this.http
.get<unknown>("/api/reservations", { params: toParams(filter) })
.pipe(map((data) => reservationListSchema.parse(data)));
}Une erreur de schéma devient alors une exception immédiate et localisée, au lieu d'un undefined qui remonte jusqu'à l'affichage.
OnPush partout, dès le premier jour
Activer ChangeDetectionStrategy.OnPush en cours de route sur une application existante est un chantier. L'activer dès le départ ne coûte rien et impose de bonnes habitudes : entrées immuables, pas de mutation de tableau en place.
@Component({
selector: "app-reservation-row",
changeDetection: ChangeDetectionStrategy.OnPush,
// …
})Combiné aux signaux, le rendu ne se déclenche que sur les composants réellement concernés. Sur un planning affichant deux cents lignes, la différence est immédiatement perceptible au défilement.
Formulaires réactifs typés, jamais de any
readonly form = new FormGroup({
guestName: new FormControl("", { nonNullable: true, validators: [Validators.required] }),
nights: new FormControl(1, { nonNullable: true, validators: [Validators.min(1)] }),
});Le nonNullable: true est ce qui évite la cascade de | null dans tout le code appelant. C'est trois caractères qui épargnent des dizaines de vérifications défensives.
Ce que je ne fais plus
- Des services « god object » qui portent l'état de plusieurs fonctionnalités. Un store par domaine, dans son dossier.
- Des
subscribe()dans les composants sans désabonnement.asyncdans le template, outoSignal(), règlent le problème par construction. - Un
shared/fourre-tout. Un composant n'y entre qu'à partir du moment où il est utilisé par deux fonctionnalités distinctes, pas « au cas où ».
La mesure qui compte : un développeur qui rejoint le projet doit pouvoir livrer une correction sur un écran qu'il n'a jamais vu, sans ouvrir plus de trois fichiers.
Articles liés
Structurer un projet Next.js App Router qui tient sur la durée
Les conventions de dossiers, de composants serveur et de récupération de données que j'applique sur tous mes projets Next.js depuis deux ans.
- Next.js
- Architecture
- TypeScript
Intégrer les API de paiement des agrégateurs sans se faire piéger
Mobile Money, cartes bancaires, agrégateurs multiples : le modèle unifié, l'idempotence et la réconciliation qui évitent les doubles débits en production.
- Paiement
- FinTech
- Architecture
- API