Retour au cours

frontend / vuejs

Architecture d'une grosse application — niveau expert

Leçon 221 exercice

Explication

Ce que vous allez apprendre

  • Organiser un projet Vue par domaine métier plutôt que par type de fichier
  • Isoler tout accès réseau derrière une couche API dédiée et testable
  • Centraliser la logique HTTP transverse (authentification, retry) dans un client unique
  • Construire des composables "façade" qui masquent la complexité interne
  • Surveiller et limiter la taille des bundles à l'échelle d'une application qui grossit

Dans quel contexte ?

Une équipe de huit développeurs travaille sur la même application Vue depuis deux ans. Le dossier components/ contient désormais plus de 200 fichiers mélangés sans logique apparente, et ajouter une fonctionnalité au panier oblige à modifier des fichiers dispersés dans components/, composables/, store/ et api/. Cette dispersion ralentit chaque changement et complique l'intégration de nouveaux développeurs. Une réorganisation par domaine métier résout ce problème structurellement.

D'abord, le principe central : découper par domaine, pas par type de fichier

Plutôt qu'un dossier components/ géant contenant tout, chaque domaine métier (panier, catalogue, authentification) devient un module auto-suffisant regroupant ses propres composants, composables, store et accès API. Ajouter une fonctionnalité au panier ne touche alors qu'au dossier modules/panier/, jamais au reste de l'application.

Prérequis

Cette leçon suppose une maîtrise solide de Pinia, de Vue Router et des composables, tous vus dans les leçons précédentes — c'est leur combinaison qui rend ce découpage possible.

DossierRôle
app/Bootstrap de l'application (plugins, router, Pinia)
modules/<domaine>/Code auto-suffisant d'un domaine métier précis
shared/Code transverse réutilisé par plusieurs modules
router/index.tsAgrège les routes exposées par chaque module

Une fois les modules définis, un problème classique doit être anticipé : l'accès réseau dispersé

Si chaque composant appelle fetch() directement, changer la forme d'une réponse API ou ajouter un intercepteur d'authentification oblige à modifier des dizaines de fichiers. Une couche API dédiée (produitsApi) isole cet accès réseau : les composants et stores appellent toujours cette couche, jamais fetch() directement, ce qui la rend aussi facilement remplaçable en test (mock).

Piège courant

Disperser des appels fetch() directement dans des composants semble plus rapide à court terme, mais devient une dette technique majeure dès que l'application grandit : impossible de centraliser la gestion des erreurs, du retry ou du rafraîchissement de token sans modifier chaque appel individuellement.

Il reste un besoin transverse à tous les modules : la logique HTTP commune

Un httpClient centralisé gère l'ajout automatique du token d'authentification, la détection d'une expiration de session (401) avec rafraîchissement automatique et un seul retry, et l'uniformisation de la gestion d'erreur. Chaque module d'API métier (comme produitsApi) s'appuie sur ce client commun plutôt que de réimplémenter cette logique.

Enfin, deux derniers outils consolident cette architecture à grande échelle

Un composable "façade" comme usePanier masque la complexité interne (store Pinia, système de notifications) derrière une API simple que les composants consomment sans connaître les détails d'implémentation sous-jacents. Et côté performance, manualChunks dans la configuration Vite sépare les dépendances stables (Vue, Vue Router, Pinia) du code applicatif qui change souvent, améliorant le cache navigateur entre déploiements.

Bonne pratique

Ajoute un chunkSizeWarningLimit dans la CI dès qu'un projet dépasse une taille modeste. Sans surveillance active, la taille des bundles JavaScript augmente insidieusement au fil des mois, et personne ne remarque la dégradation avant qu'un utilisateur ne se plaigne d'un chargement lent.

Ce parcours Vue touche ici à sa fin : des bases de la réactivité jusqu'à l'architecture d'une application d'équipe en production, tu disposes maintenant d'une vision complète pour concevoir des applications Vue robustes, performantes et maintenables à long terme.

Commandes & code

Architecture d'une grosse application — niveau expert

Organiser un projet Vue à l'échelle d'une équipe : modularité, frontières claires, testabilité, performance.

bash
src/
├── app/                    # bootstrap de l'application (plugins, router, pinia)
│   ├── providers/          # ex: configuration i18n, Sentry, analytics
│   └── main.ts
├── modules/                 # découpage par DOMAINE MÉTIER, pas par type de fichier
│   ├── panier/
│   │   ├── components/
│   │   ├── composables/
│   │   ├── stores/
│   │   ├── api/
│   │   ├── types.ts
│   │   └── routes.ts        # module auto-suffisant : ajoute ses propres routes
│   ├── catalogue/
│   └── authentification/
├── shared/                  # code transverse réutilisé par plusieurs modules
│   ├── ui/                  # composants génériques (Bouton, Modale, Champ...)
│   ├── composables/
│   └── utils/
└── router/
    └── index.ts              # agrège les routes de chaque module
ts
// modules/panier/routes.ts — chaque module expose ses propres routes, agrégées au niveau global
import type { RouteRecordRaw } from 'vue-router'

export const routesPanier: RouteRecordRaw[] = [
  { path: '/panier', name: 'panier', component: () => import('./pages/PagePanier.vue') },
]
ts
// router/index.ts — agrégation : ajouter un module ne touche qu'à UN fichier
import { createRouter, createWebHistory } from 'vue-router'
import { routesPanier } from '../modules/panier/routes'
import { routesCatalogue } from '../modules/catalogue/routes'
import { routesAuth } from '../modules/authentification/routes'

export const router = createRouter({
  history: createWebHistory(),
  routes: [...routesPanier, ...routesCatalogue, ...routesAuth],
})
ts
// modules/catalogue/api/produitsApi.ts — couche d'accès aux données ISOLÉE des composants
// (facilement mockable en test, et remplaçable si le backend change de forme)
import { httpClient } from '@/shared/api/httpClient'
import type { Produit } from '../types'

export const produitsApi = {
  lister: (filtres?: { categorie?: string }) =>
    httpClient.get<Produit[]>('/produits', { params: filtres }),
  obtenir: (id: number) =>
    httpClient.get<Produit>(`/produits/${id}`),
  creer: (payload: Omit<Produit, 'id'>) =>
    httpClient.post<Produit>('/produits', payload),
}

// Les composants/stores appellent produitsApi, JAMAIS fetch() directement :
// un seul endroit à modifier pour changer d'intercepteur, gérer le retry, ou muter l'API.
ts
// shared/api/httpClient.ts — client HTTP centralisé : auth, retry, gestion d'erreur uniformisée
import { useAuthStore } from '@/modules/authentification/stores/auth'

async function requete<T>(url: string, options: RequestInit = {}): Promise<T> {
  const auth = useAuthStore()

  const reponse = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(auth.token ? { Authorization: `Bearer ${auth.token}` } : {}),
      ...options.headers,
    },
  })

  if (reponse.status === 401) {
    await auth.rafraichirToken()
    return requete<T>(url, options) // un seul retry après rafraîchissement du token
  }
  if (!reponse.ok) {
    throw new Error(`Erreur API ${reponse.status} sur ${url}`)
  }
  return reponse.json()
}

export const httpClient = {
  get: <T>(url: string, opts?: RequestInit) => requete<T>(url, { ...opts, method: 'GET' }),
  post: <T>(url: string, body: unknown, opts?: RequestInit) =>
    requete<T>(url, { ...opts, method: 'POST', body: JSON.stringify(body) }),
}
ts
// Pattern "façade" pour un composable complexe : masque la complexité interne derrière une API simple
// modules/panier/composables/usePanier.ts
import { storeToRefs } from 'pinia'
import { usePanierStore } from '../stores/panier'
import { useNotifications } from '@/shared/composables/useNotifications'

export function usePanier() {
  const store = usePanierStore()
  const { articles, total } = storeToRefs(store)
  const { notifier } = useNotifications()

  async function ajouterAvecFeedback(produit: { id: number; nom: string; prix: number }) {
    store.ajouter(produit)
    notifier(`${produit.nom} ajouté au panier`, { type: 'succes' })
  }

  return { articles, total, ajouterAvecFeedback }
}
// Le composant n'a besoin de connaître NI le store, NI le système de notifications séparément.
ts
// Convention de performance à grande échelle : budget de bundle surveillé en CI
// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // sépare les grosses dépendances stables du code applicatif (meilleur cache navigateur)
          'vendor-vue': ['vue', 'vue-router', 'pinia'],
          'vendor-ui': ['@vueuse/core'],
        },
      },
    },
    chunkSizeWarningLimit: 300, // avertit en CI si un chunk dépasse 300 Ko
  },
})

Résumé

  • Découper par domaine métier (modules auto-suffisants) plutôt que par type de fichier plat à l'échelle du projet.
  • Isoler tout accès réseau derrière une couche API dédiée, jamais de fetch disséminé dans les composants.
  • Les composables "façade" masquent la complexité (store + notifications + logique) derrière une API simple.
  • Surveiller activement la taille des bundles (manualChunks, budgets CI) dès que l'application grossit.

Exercices pratiques

1 disponible
1

Mission : une boucle infinie de rafraîchissement de token

Objectif : Diagnostiquer un retry HTTP non borné dans un client centralisé, puis corriger l'architecture pour respecter les frontières entre modules.

Contexte

En production, certains utilisateurs avec un token définitivement invalide (compte désactivé) voient leur navigateur se figer sur une page qui charge indéfiniment, avec des dizaines d'appels à /api/auth/refresh visibles dans l'onglet réseau. Le code de httpClient.ts correspond exactement à celui de cette leçon : if (reponse.status === 401) { await auth.rafraichirToken(); return requete<T>(url, options) }, sans aucun compteur de tentative, alors que le commentaire du code affirme "un seul retry après rafraîchissement du token".

Résoudre l’exercice →