MFormations
Modern Frontend Engineering

Chapitre 8

Chapitre 08 — Architecture Front-End

Chapitre 08 — Architecture Front-End

Chapitre 08 — Architecture Front-End : Cours Complet

1. Pourquoi l'Architecture est Cruciale

1.1 Le problème de la non-architecture

Sans architecture, une application front-end évolue inévitablement vers le Big Ball of Mud (grosse boue) :

  • Composants qui mélangent UI, état, logique métier et appels API
  • Dépendances circulaires entre modules
  • Code dupliqué car personne ne sait où mettre les choses
  • Tests impossibles sans mock de tout l'univers
  • Onboarding d'un nouveau développeur : 3 semaines

1.2 Les signes d'une mauvaise architecture

  • Un changement dans le design UI casse la logique métier
  • Un changement de base de données ou d'API impacte tous les composants
  • Impossible de tester unitairement la logique métier
  • Le bundle fait 5MB pour une app "simple"
  • Les imports sont chaotiques (../../utils/helpers/foo/bar/baz)
  • Les composants "fat" de 500+ lignes

1.3 Objectifs d'une bonne architecture

✅ Maintenable      — Un développeur peut modifier le code sans tout casser
✅ Testable         — Chaque couche peut être testée indépendamment
✅ Évolutive        — On peut ajouter des fonctionnalités sans réécrire
✅ Performante      — L'architecture ne doit pas impacter les perfs
✅ Remplaçable      — On peut changer une librairie sans tout réécrire
✅ Compréhensible   — Un nouveau développeur comprend la structure rapidement

2. Principes SOLID en Front-End

2.1 Single Responsibility Principle (SRP)

Chaque module/fonction/composant doit avoir une seule raison de changer.

// ❌ Mauvais : ce composant gère UI + logique métier + API + state
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(setUser)
      .finally(() => setIsLoading(false));
  }, [userId]);

  if (isLoading) return <Spinner />;
  if (!user) return <Error />;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <button onClick={() => /* modifier le user */}>Edit</button>
    </div>
  );
}

// ✅ Bon : séparation des responsabilités
function useUser(userId: string) { // Hook : logique de données
  return useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId) });
}

function UserProfile({ userId }: { userId: string }) { // Composant : UI uniquement
  const { data: user, isLoading, error } = useUser(userId);

  if (isLoading) return <Spinner />;
  if (error) return <Error error={error} />;
  if (!user) return <Empty />;

  return <UserProfileView user={user} />;
}

function UserProfileView({ user }: { user: User }) { // Vue : presentation pure
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

2.2 Open/Closed Principle (OCP)

Les modules doivent être ouverts à l'extension, fermés à la modification.

// ❌ Mauvais : ajouter un type de notification = modifier ce code
function Notification({ type, message }: { type: string; message: string }) {
  if (type === "success") return <div className="green">{message}</div>;
  if (type === "error") return <div className="red">{message}</div>;
  if (type === "warning") return <div className="yellow">{message}</div>;
  return <div>{message}</div>;
}

// ✅ Bon : Strategy Pattern
interface NotificationStrategy {
  render(message: string): React.ReactNode;
}

const strategies: Record<string, NotificationStrategy> = {
  success: { render: (m) => <div className="green">{m}</div> },
  error: { render: (m) => <div className="red">{m}</div> },
  warning: { render: (m) => <div className="yellow">{m}</div> },
};

function Notification({ type, message }: { type: string; message: string }) {
  const strategy = strategies[type] ?? strategies.success;
  return strategy.render(message);
}
// Ajouter "info" = ajouter une stratégie, pas modifier Notification

2.3 Liskov Substitution Principle (LSP)

Les sous-types doivent être substituables à leurs types de base.

interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean;
}

// Ces composants doivent pouvoir remplacer Button sans casser l'app
function PrimaryButton(props: ButtonProps) { return <button className="primary" {...props} />; }
function SecondaryButton(props: ButtonProps) { return <button className="secondary" {...props} />; }
function DangerButton(props: ButtonProps) { return <button className="danger" {...props} />; }

2.4 Interface Segregation Principle (ISP)

Ne pas imposer à un composant des props qu'il n'utilise pas.

// ❌ Mauvais : interface grosse qui force des props inutiles
interface UserFormProps {
  id: string;
  name: string;
  email: string;
  role: string;
  createdAt: string;
  updatedAt: string;
  lastLogin: string;
  // ... 20 autres champs
}

// ✅ Bon : interfaces spécifiques
interface UserBasicInfo { name: string; email: string; }
interface UserFormActions { onSave: (data: UserBasicInfo) => void; onCancel: () => void; }
type UserFormProps = UserBasicInfo & UserFormActions;

2.5 Dependency Inversion Principle (DIP)

Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d'abstractions.

// ❌ Mauvais : dépendance directe à une implémentation concrète
function UserList() {
  const [users, setUsers] = useState([]);
  useEffect(() => {
    fetch("/api/users").then(r => r.json()).then(setUsers);
  }, []);
  // Si on change d'API (GraphQL, tRPC), on doit modifier ce composant
}

// ✅ Bon : abstraction de la source de données
interface UserRepository {
  getUsers(): Promise<User[]>;
}

class ApiUserRepository implements UserRepository {
  async getUsers(): Promise<User[]> {
    const r = await fetch("/api/users");
    return r.json();
  }
}

class GraphQLUserRepository implements UserRepository {
  async getUsers(): Promise<User[]> {
    return gql`{ users { id name email } }`;
  }
}

function useUsers(repository: UserRepository) {
  return useQuery({ queryKey: ["users"], queryFn: () => repository.getUsers() });
}

// Dans le composant, on ne depend que de l'abstraction UserRepository
function UserList({ repository }: { repository: UserRepository }) {
  const { data: users } = useUsers(repository);
  // ...
}

3. Clean Architecture

3.1 Les cercles

┌────────────────────────────────────────────┐
│         Frameworks & Drivers               │
│   (React, Next.js, axios, etc.)            │
│  ┌──────────────────────────────────────┐  │
│  │      Interface Adapters              │  │
│  │  (Controllers, Presenters, Gateways) │  │
│  │ ┌────────────────────────────────┐   │  │
│  │ │    Application / Use Cases    │   │  │
│  │ │ (Actions, Interactors, etc.)  │   │  │
│  │ │ ┌──────────────────────────┐  │   │  │
│  │ │ │  Domain / Entities       │  │   │  │
│  │ │ │ (Business Logic, Rules)  │  │   │  │
│  │ │ └──────────────────────────┘  │   │  │
│  │ └────────────────────────────────┘   │  │
│  └──────────────────────────────────────┘  │
└────────────────────────────────────────────┘

3.2 Application au front-end React

// ─── Domaine (Entities) ───
interface Todo {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
}

// ─── Use Cases ───
class AddTodoUseCase {
  constructor(private todoRepository: TodoRepository) {}

  async execute(title: string): Promise<Todo> {
    if (title.length < 3) throw new Error("Title too short");
    return this.todoRepository.save({ title, completed: false });
  }
}

class ToggleTodoUseCase {
  constructor(private todoRepository: TodoRepository) {}

  async execute(id: string): Promise<Todo> {
    const todo = await this.todoRepository.findById(id);
    if (!todo) throw new Error("Todo not found");
    return this.todoRepository.update(id, { completed: !todo.completed });
  }
}

// ─── Ports (Abstractions) ───
interface TodoRepository {
  findAll(): Promise<Todo[]>;
  findById(id: string): Promise<Todo | null>;
  save(todo: Omit<Todo, "id" | "createdAt">): Promise<Todo>;
  update(id: string, data: Partial<Todo>): Promise<Todo>;
  delete(id: string): Promise<void>;
}

// ─── Adapters (Implémentations concrètes) ───
class ApiTodoRepository implements TodoRepository {
  async findAll(): Promise<Todo[]> {
    const r = await fetch("/api/todos");
    return r.json();
  }
  // ...
}

// ─── React Adapter (Interface utilisateur) ───
function useTodos(repository: TodoRepository) {
  const queryClient = useQueryClient();

  const query = useQuery({ queryKey: ["todos"], queryFn: () => repository.findAll() });

  const addTodo = useMutation({
    mutationFn: (title: string) => new AddTodoUseCase(repository).execute(title),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
  });

  return { todos: query.data, isLoading: query.isLoading, addTodo };
}

4. Architecture Hexagonale (Ports & Adapters)

L'architecture hexagonale (Alistair Cockburn) met l'accent sur le concept de ports (interfaces) et adapters (implémentations). Le domaine est au centre, complètement isolé des technologies externes.

// ─── Ports ───
// Primary ports : l'application expose ces interfaces
interface TodoService {
  getTodos(): Promise<Todo[]>;
  createTodo(title: string): Promise<Todo>;
  completeTodo(id: string): Promise<Todo>;
}

// Secondary ports : l'application a besoin de ces services
interface TodoPersistence {
  findAll(): Promise<Todo[]>;
  save(todo: Todo): Promise<Todo>;
}

interface Logger {
  info(message: string): void;
  error(message: string, error: unknown): void;
}

// ─── Application Core (Domain) ───
class TodoServiceImpl implements TodoService {
  constructor(
    private persistence: TodoPersistence,
    private logger: Logger
  ) {}

  async getTodos(): Promise<Todo[]> {
    this.logger.info("Fetching todos");
    return this.persistence.findAll();
  }

  async createTodo(title: string): Promise<Todo> {
    if (!title.trim()) throw new Error("Title is required");
    const todo = { id: crypto.randomUUID(), title, completed: false, createdAt: new Date() };
    return this.persistence.save(todo);
  }

  async completeTodo(id: string): Promise<Todo> {
    const todos = await this.persistence.findAll();
    const todo = todos.find(t => t.id === id);
    if (!todo) throw new Error("Todo not found");
    return this.persistence.save({ ...todo, completed: true });
  }
}

// ─── Adapters ───
// Primary adapter : React hook
function useTodoService(service: TodoService) {
  return useQuery({ queryKey: ["todos"], queryFn: () => service.getTodos() });
}

// Secondary adapter : API REST
class ApiTodoPersistence implements TodoPersistence {
  async findAll(): Promise<Todo[]> { /* ... */ }
  async save(todo: Todo): Promise<Todo> { /* ... */ }
}

// Secondary adapter : Console Logger
class ConsoleLogger implements Logger {
  info(m: string) { console.info(`[INFO] ${m}`); }
  error(m: string, e: unknown) { console.error(`[ERROR] ${m}`, e); }
}

5. Domain-Driven Design (DDD) pour le Front-End

5.1 Bounded Context

Dans une application de e-commerce :

┌─────────────────────────────────────────────┐
│  Bounded Context : Catalogue                │
│  Ubiquitous Language : Product, Category,   │
│  Price, Stock, Variant, SKU                 │
├─────────────────────────────────────────────┤
│  Bounded Context : Panier                   │
│  Ubiquitous Language : Cart, CartItem,       │
│  Coupon, Tax, Shipping, Total               │
├─────────────────────────────────────────────┤
│  Bounded Context : Commande                  │
│  Ubiquitous Language : Order, OrderLine,     │
│  Invoice, Payment, Shipment, Status         │
└─────────────────────────────────────────────┘

5.2 Feature Sliced Design (FSD)

FSD est une méthodologie architecturale qui organise le code par fonctionnalités métier plutôt que par type technique.

src/
  app/                          — Configuration, provider, router
    providers.tsx
    router.tsx
    styles.css

  pages/                        — Pages de l'application
    home/
    catalog/
    cart/
    checkout/

  features/                     — Fonctionnalités métier réutilisables
    auth/
      ui/                       — Composants UI de l'auth
      api/                      — Appels API liés à l'auth
      model/                    — Types et logique métier
      lib/                      — Utilitaires
    cart/
      ui/
      api/
      model/
      lib/
    search/
      ui/
      api/
      model/

  entities/                     — Entités métier (Product, User, Order)
    product/
      ui/                       — Composants liés à Product
      api/
      model/
    user/
      ui/
      api/
      model/

  shared/                       — Code partagé (UI kit, utils)
    ui/                         — Atomes (Button, Input, Card)
    lib/                        — Utilitaires génériques
    api/                        — Client HTTP
    config/                     — Configuration globale

├── app/                        — Layer applicative (routage, providers)
├── processes/                  — Processus métier (optionnel)
├── pages/                      — Pages composées de features/entities
├── features/                   — Fonctionnalités utilisateur
├── entities/                   — Entités métier
├── shared/                     — Infrastructure partagée

Règle d'or FSD : une couche ne peut importer que les couches en dessous (jamais au-dessus).

  • pagesfeatures, entities, shared
  • featuresentities, shared
  • entitiesshared
  • shared → rien (ou librairies externes)

6. MVC, MVVM, MVP

6.1 MVC (Model-View-Controller)

┌─────────┐     ┌──────────┐     ┌──────────┐
│  Model  │◄───►│  View    │◄───►│Controller│
│ (Données│     │   (UI)   │     │ (Logique)│
│  métier)│     │          │     │          │
└─────────┘     └──────────┘     └──────────┘

React n'est pas MVC, mais on peut voir les hooks comme des contrôleurs et le JSX comme la vue.

6.2 MVVM (Model-View-ViewModel)

┌─────────┐     ┌──────────┐     ┌──────────┐
│  Model  │◄───►│ViewModel │◄───►│   View   │
│ (Données│     │ (État +  │     │   (UI)   │
│  métier)│     │ Logique) │     │          │
└─────────┘     └──────────┘     └──────────┘

Le ViewModel expose l'état réactif que la Vue observe. Zustand + React correspond à ce pattern.

6.3 Comparaison

PatternSéparationTestabilitéComplexitéReact
MVC⚠️ Partielle⚠️ Moyenne✅ FaibleComposants + Hooks
MVVM✅ Bonne✅ Haute⚠️ ModéréeZustand + Composants
Clean/Hexagonal✅ Excellente✅ Très haute❌ ÉlevéePorts & Adapters
FSD✅ Bonne✅ Haute⚠️ ModéréeFeature folders

7. Atomic Design (Brad Frost)

7.1 Les 5 niveaux

┌─────────────────────────────────────────┐
│          PAGES (Pages)                  │
│  Ex: HomePage, ProductPage, CartPage    │
│ ┌───────────────────────────────────┐   │
│ │      TEMPLATES (Templates)        │   │
│ │  Ex: PageLayout, AuthLayout       │   │
│ │ ┌─────────────────────────────┐   │   │
│ │ │   ORGANISMS (Organismes)    │   │   │
│ │ │  Ex: Header, ProductCard,   │   │   │
│ │ │  Footer, Navigation,        │   │   │
│ │ │  SearchForm                 │   │   │
│ │ │ ┌───────────────────────┐   │   │   │
│ │ │ │  MOLECULES            │   │   │   │
│ │ │ │ Ex: InputGroup,       │   │   │   │
│ │ │ │ Card, Modal, Tabs,    │   │   │   │
│ │ │ │ Dropdown, Pagination  │   │   │   │
│ │ │ │ ┌─────────────────┐   │   │   │   │
│ │ │ │ │   ATOMES        │   │   │   │   │
│ │ │ │ │ Ex: Button,     │   │   │   │   │
│ │ │ │ │ Input, Label,   │   │   │   │   │
│ │ │ │ │ Icon, Badge,    │   │   │   │   │
│ │ │ │ │ Tag, Spinner    │   │   │   │   │
│ │ │ │ └─────────────────┘   │   │   │   │
│ │ │ └───────────────────────┘   │   │   │
│ │ └─────────────────────────────┘   │   │
│ └───────────────────────────────────┘   │
└─────────────────────────────────────────┘

7.2 Exemple d'implémentation

// Atome
function Button({ variant, size, children, ...props }: ButtonProps) {
  return <button className={`btn btn-${variant} btn-${size}`} {...props}>{children}</button>;
}

// Molécule
function InputGroup({ label, error, children }: InputGroupProps) {
  return (
    <div className="input-group">
      <Label text={label} />
      {children}
      {error && <ErrorMessage message={error} />}
    </div>
  );
}

// Organisme
function ProductCard({ product, onAddToCart }: ProductCardProps) {
  return (
    <Card>
      <Card.Image src={product.image} />
      <Card.Body>
        <Heading level={3}>{product.name}</Heading>
        <Text>{product.description}</Text>
        <Badge variant={product.inStock ? "success" : "error"}>
          {product.inStock ? "En stock" : "Rupture"}
        </Badge>
        <Price amount={product.price} currency="EUR" />
        <Button variant="primary" onClick={() => onAddToCart(product)}>
          Ajouter au panier
        </Button>
      </Card.Body>
    </Card>
  );
}

8. Gestion des États (Loading, Empty, Error, Success)

8.1 Pattern systématique

type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

// Composant générique pour gérer tous les états
function AsyncRenderer<T>({ state, onSuccess }: {
  state: AsyncState<T>;
  onSuccess: (data: T) => React.ReactNode;
}) {
  switch (state.status) {
    case "idle":
      return <InitialMessage />;
    case "loading":
      return <LoadingSkeleton />;
    case "error":
      return <ErrorFallback error={state.error} onRetry={() => {/* retry */}} />;
    case "success":
      if (Array.isArray(state.data) && state.data.length === 0) {
        return <EmptyState message="Aucune donnée" />;
      }
      return <>{onSuccess(state.data)}</>;
  }
}

9. Comparaison des Approches de Structure

ApprochePoints fortsPoints faiblesQuand l'utiliser
Atomic DesignUI cohérente, réutilisableTrop rigide pour la logique métierDesign System, bibliothèque de composants
Feature SlicedAligné métier, scalableCourbe d'apprentissageApplications enterprise complexes
Clean ArchitectureTestable, découpléBeaucoup de boilerplateApplications critiques, domain-driven
Par dossier techniqueSimple, familierDésorganisé à grande échellePetits projets, prototypes
Par pageSimple, contextuelDuplicationLanding pages, sites statiques

10. Anti-patterns Architecturaux

❌ God Component : un composant qui fait tout (data fetching + UI + state)
❌ Props Drilling : passer des props sur 5+ niveaux sans contexte
❌ Global State partout : tout dans Redux même l'état local d'un input
❌ Pas de séparation métier/technique : logique métier mélangée aux appels API
❌ Dépendances circulaires : A importe B qui importe A
❌ Barrel files monstres : index.ts qui exporte tout le module
❌ Couplage fort au framework : impossible de migrer de React vers Vue sans tout réécrire

11. Composition vs Héritage

React favorise la composition :

// ❌ Héritage (pas idiomatique React)
class AdminPage extends BasePage { /* ... */ }

// ✅ Composition
function AdminPage() {
  return (
    <Page>
      <Page.Header>
        <AdminHeader />
      </Page.Header>
      <Page.Content>
        <AdminContent />
      </Page.Content>
    </Page>
  );
}

12. Architecture Micro-Frontends (Préparation)

// Module Federation (Webpack 5)
// micro-frontend-host/webpack.config.js
new ModuleFederationPlugin({
  name: "host",
  remotes: {
    catalogue: "catalogue@http://localhost:3001/remoteEntry.js",
    cart: "cart@http://localhost:3002/remoteEntry.js",
    checkout: "checkout@http://localhost:3003/remoteEntry.js",
  },
});

// Dans le code
const CataloguePage = React.lazy(() => import("catalogue/CataloguePage"));
const CartWidget = React.lazy(() => import("cart/CartWidget"));

function App() {
  return (
    <Suspense fallback={<BigSpinner />}>
      <CataloguePage />
      <CartWidget />
    </Suspense>
  );
}

13. Recommandations Finales

  1. Commencez simple : Atomic Design + Feature folders
  2. Séparez toujours : UI / Logique métier / Infrastructure / État
  3. DDD par bounded context pour organiser les fonctionnalités
  4. Inversion de dépendance pour les services externes
  5. Un composant = une responsabilité (max 200 lignes)
  6. Testez la logique métier, pas les détails d'implémentation React
  7. L'architecture doit évoluer : ne sur-ingenieriez pas au début
  8. Documentez les décisions (ADR) pour les choix architecturaux importants