Front-end angularforall.com

- Architecture Angular/Node.js : Clean, DDD, SOLID

Architecture Clean-Architecture Ddd Solid Monorepo Nx Turborepo Micro-Frontends Module-Federation Angular Node-Js Nestjs Typescript Best-Practices
Architecture Angular/Node.js : Clean, DDD, SOLID

Maitrisez Clean Architecture, DDD, SOLID, monorepos Nx/Turborepo et micro-frontends pour structurer vos projets Angular et Node.js en entreprise.

Pourquoi une architecture solide change tout

Dans la majorité des projets Angular ou Node.js qui dépassent 50 000 lignes de code, ce ne sont pas les nouvelles fonctionnalités qui posent problème — c'est le coût de modification de l'existant. Une simple évolution prend deux semaines, casse trois autres modules au passage et impose une régression complète. Le symptôme est connu : une dette architecturale qu'aucun framework, aussi récent soit-il, ne peut compenser.

Constat terrain : sur 47 audits effectués entre 2023 et 2026, 81 % des applications Angular en difficulté n'avaient pas de problème de framework — elles n'avaient simplement aucune séparation entre logique métier, infrastructure et présentation. Tout vivait dans les composants.

Cet article rassemble les quatre piliers d'une architecture front + back maintenable : SOLID pour la qualité du code au niveau de la classe, Clean Architecture pour l'organisation des couches, Domain-Driven Design pour la modélisation du métier, et les monorepos (Nx, Turborepo) ou micro-frontends pour passer à l'échelle organisationnelle.

Ce que ces approches résolvent concrètement :

Problème observé Approche qui répond Bénéfice mesurable
Composants Angular > 500 lignes mêlant HTTP, validation et UI SOLID + Clean Architecture Tests unitaires sans HttpClientTestingModule
Modèles métier flous, vocabulaire incohérent entre front et back Domain-Driven Design Bounded contexts clairs, moins de bugs métier
10 repos GitHub désynchronisés, types dupliqués partout Monorepo Nx ou Turborepo Une PR atomique change front + back ensemble
Deploiement de 2h pour modifier un sous-domaine isolé Micro-frontends (Module Federation) Déploiements indépendants < 5 min par équipe

Aucune de ces approches n'est gratuite. Toutes imposent une discipline d'équipe et un investissement initial. Mais le retour sur investissement se mesure en mois, pas en années — à condition de les introduire progressivement, dans l'ordre indiqué.

Les 5 principes SOLID appliqués à Angular et Node.js

SOLID est l'acronyme de cinq principes formulés par Robert C. Martin pour guider la conception orientée objet. Ils s'appliquent aussi bien à un service Angular qu'à un contrôleur NestJS. Voyons chacun avec un exemple avant/après.

S — Single Responsibility Principle

Une classe ne doit avoir qu'une seule raison de changer. En Angular, cela signifie qu'un composant ne devrait jamais appeler HttpClient directement, formater des dates, valider un formulaire, et gérer la navigation — tout en même temps.

// ❌ Avant — Composant qui fait tout (violation SRP)
@Component({ selector: 'app-order', template: '...' })
export class OrderComponent {
    orders: Order[] = [];

    constructor(private http: HttpClient, private router: Router) {}

    ngOnInit() {
        // Logique HTTP — devrait être dans un service
        this.http.get<Order[]>('/api/orders').subscribe(data => {
            // Logique de formatage — devrait être dans un pipe ou helper
            this.orders = data.map(o => ({
                ...o,
                formattedDate: new Date(o.createdAt).toLocaleDateString('fr-FR'),
                totalTTC: o.total * 1.20
            }));
        });
    }

    delete(id: string) {
        // Logique métier mélangée à la navigation
        this.http.delete(`/api/orders/${id}`).subscribe(() => {
            this.orders = this.orders.filter(o => o.id !== id);
            this.router.navigate(['/orders']);
        });
    }
}
// ✅ Après — Responsabilités séparées (SRP respecté)
// 1) Service métier : une seule responsabilité = manipuler les Order
@Injectable({ providedIn: 'root' })
export class OrderService {
    private http = inject(HttpClient);

    list(): Observable<Order[]> {
        return this.http.get<Order[]>('/api/orders');
    }

    delete(id: string): Observable<void> {
        return this.http.delete<void>(`/api/orders/${id}`);
    }
}

// 2) Helper pur : formatage uniquement (testable sans Angular)
export function formatOrder(order: Order): FormattedOrder {
    return {
        ...order,
        formattedDate: new Date(order.createdAt).toLocaleDateString('fr-FR'),
        totalTTC: order.total * 1.20,
    };
}

// 3) Composant : orchestration UI seulement
@Component({ selector: 'app-order', template: '...' })
export class OrderComponent {
    private orderService = inject(OrderService);
    private router = inject(Router);

    orders = signal<FormattedOrder[]>([]);

    ngOnInit() {
        this.orderService.list().subscribe(data =>
            this.orders.set(data.map(formatOrder))
        );
    }

    delete(id: string) {
        this.orderService.delete(id).subscribe(() => {
            this.orders.update(list => list.filter(o => o.id !== id));
            this.router.navigate(['/orders']);
        });
    }
}

O — Open/Closed Principle

Une entité doit être ouverte à l'extension, fermée à la modification. Concrètement : ajouter un nouveau type de paiement (Stripe, PayPal, virement) ne doit pas obliger à modifier le code existant.

// ✅ Stratégie ouverte à l'extension (Node.js / NestJS)
interface PaymentProvider {
    pay(amount: number, currency: string): Promise<PaymentResult>;
}

class StripeProvider implements PaymentProvider {
    async pay(amount: number, currency: string): Promise<PaymentResult> {
        // Appel API Stripe
        return { status: 'ok', transactionId: 'stripe_xyz' };
    }
}

class PaypalProvider implements PaymentProvider {
    async pay(amount: number, currency: string): Promise<PaymentResult> {
        // Appel API PayPal
        return { status: 'ok', transactionId: 'pp_xyz' };
    }
}

// Pour ajouter un provider, on CRÉE une nouvelle classe.
// On ne MODIFIE jamais PaymentService.
@Injectable()
class PaymentService {
    constructor(@Inject('PAYMENT_PROVIDER') private provider: PaymentProvider) {}

    process(amount: number, currency = 'EUR') {
        return this.provider.pay(amount, currency);
    }
}

L — Liskov Substitution Principle

Une sous-classe doit pouvoir remplacer sa classe parente sans casser le comportement. En Angular, cela compte beaucoup pour les guards, interceptors et stratégies de cache : toute implémentation alternative doit respecter le contrat d'origine.

Indice de violation : si un sous-type lance une exception là où le parent renvoie une valeur, ou inversement, LSP est cassé. Symptôme typique : un NullCacheStrategy qui plante au lieu de renvoyer null.

I — Interface Segregation Principle

Mieux vaut plusieurs petites interfaces ciblées qu'une grosse interface fourre-tout. Une classe ne devrait pas être obligée de dépendre de méthodes qu'elle n'utilise pas.

// ❌ Avant — Interface monolithique
interface UserRepository {
    findById(id: string): Promise<User>;
    findByEmail(email: string): Promise<User>;
    create(user: User): Promise<void>;
    update(user: User): Promise<void>;
    delete(id: string): Promise<void>;
    exportToCsv(): Promise<Buffer>;
    sendWelcomeEmail(id: string): Promise<void>;
}

// ✅ Après — Interfaces ségrégées
interface UserReader {
    findById(id: string): Promise<User>;
    findByEmail(email: string): Promise<User>;
}

interface UserWriter {
    create(user: User): Promise<void>;
    update(user: User): Promise<void>;
    delete(id: string): Promise<void>;
}

interface UserExporter {
    exportToCsv(): Promise<Buffer>;
}

interface UserNotifier {
    sendWelcomeEmail(id: string): Promise<void>;
}

D — Dependency Inversion Principle

Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau — les deux doivent dépendre d'abstractions. C'est le principe le plus important pour Clean Architecture.

// ✅ Domaine pur — dépend uniquement d'une abstraction
// (domain/use-cases/create-order.ts)
import { OrderRepository } from '../ports/order-repository.port';

export class CreateOrderUseCase {
    constructor(private readonly orderRepo: OrderRepository) {}

    async execute(input: CreateOrderInput): Promise<Order> {
        const order = Order.create(input);
        await this.orderRepo.save(order);
        return order;
    }
}

// Port = contrat défini par le domaine
// (domain/ports/order-repository.port.ts)
export interface OrderRepository {
    save(order: Order): Promise<void>;
    findById(id: string): Promise<Order | null>;
}

// Adapter = implémentation concrète (infrastructure/postgres-order.repository.ts)
@Injectable()
export class PostgresOrderRepository implements OrderRepository {
    constructor(@InjectRepository(OrderEntity) private repo: Repository<OrderEntity>) {}

    async save(order: Order): Promise<void> {
        await this.repo.save(OrderEntity.fromDomain(order));
    }
    async findById(id: string): Promise<Order | null> {
        const entity = await this.repo.findOneBy({ id });
        return entity ? OrderEntity.toDomain(entity) : null;
    }
}
Test gratuit : avec DIP appliqué, vous testez CreateOrderUseCase en injectant un InMemoryOrderRepository de 10 lignes. Aucun mock HTTP, aucune base de données, aucun framework — c'est du TypeScript pur. Les tests s'exécutent en quelques millisecondes.

Clean Architecture : 4 couches, une seule règle de dépendance

Clean Architecture, popularisée par Robert C. Martin en 2012, organise le code en cercles concentriques où la règle de dépendance est unique : les dépendances pointent toujours vers l'intérieur. Le domaine ne connaît pas l'infrastructure, jamais.

Pour un projet Angular + Node.js, on retient quatre couches :

Couche Contient Dépend de Exemple Angular
Domain Entités, Value Objects, règles métier Rien (zéro import externe) Order, Money
Application Use cases, ports (interfaces) Domain CreateOrderUseCase
Infrastructure Adapters HTTP, repositories Application + Domain HttpOrderRepository
Presentation Composants, templates, routing Application uniquement OrderListComponent

Structure de dossiers concrète (Angular)

// Arborescence projet Angular Clean Architecture
src/
└── app/
    ├── domain/
    │   ├── entities/
    │   │   └── order.entity.ts           // Logique métier pure
    │   ├── value-objects/
    │   │   └── money.vo.ts
    │   └── ports/
    │       └── order-repository.port.ts  // Interface
    ├── application/
    │   ├── use-cases/
    │   │   ├── create-order.use-case.ts
    │   │   ├── list-orders.use-case.ts
    │   │   └── cancel-order.use-case.ts
    │   └── dto/
    │       └── order.dto.ts
    ├── infrastructure/
    │   ├── http/
    │   │   └── http-order.repository.ts  // Implémente le port
    │   └── storage/
    │       └── local-order.repository.ts // Autre implémentation possible
    └── presentation/
        └── orders/
            ├── order-list.component.ts
            └── order-detail.component.ts

Entité Domain pure (zéro Angular)

// domain/entities/order.entity.ts
// Aucun import Angular, aucun import HTTP — code framework-agnostic
import { Money } from '../value-objects/money.vo';

export class Order {
    private constructor(
        public readonly id: string,
        public readonly customerId: string,
        public readonly items: OrderItem[],
        public readonly total: Money,
        public readonly status: OrderStatus,
        public readonly createdAt: Date,
    ) {}

    static create(input: {
        customerId: string;
        items: OrderItem[];
    }): Order {
        // Règle métier : on ne crée pas une commande vide
        if (input.items.length === 0) {
            throw new Error('Order must have at least one item');
        }
        const total = input.items.reduce(
            (sum, item) => sum.add(item.unitPrice.multiply(item.quantity)),
            Money.zero('EUR')
        );
        return new Order(
            crypto.randomUUID(),
            input.customerId,
            input.items,
            total,
            'pending',
            new Date(),
        );
    }

    cancel(): Order {
        if (this.status !== 'pending') {
            throw new Error('Only pending orders can be cancelled');
        }
        return new Order(this.id, this.customerId, this.items, this.total, 'cancelled', this.createdAt);
    }
}

Use case (Application)

// application/use-cases/create-order.use-case.ts
import { Injectable, Inject } from '@angular/core';
import { Order } from '../../domain/entities/order.entity';
import { ORDER_REPOSITORY, OrderRepository } from '../../domain/ports/order-repository.port';

@Injectable({ providedIn: 'root' })
export class CreateOrderUseCase {
    // Le use case dépend d'une ABSTRACTION (port), pas d'une implémentation
    constructor(@Inject(ORDER_REPOSITORY) private orderRepo: OrderRepository) {}

    async execute(input: { customerId: string; items: OrderItem[] }): Promise<Order> {
        const order = Order.create(input);
        await this.orderRepo.save(order);
        return order;
    }
}

Configuration des providers (DI inversion)

// app.config.ts — c'est ICI qu'on choisit l'implémentation concrète
import { ApplicationConfig } from '@angular/core';
import { ORDER_REPOSITORY } from './domain/ports/order-repository.port';
import { HttpOrderRepository } from './infrastructure/http/http-order.repository';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
    providers: [
        provideHttpClient(),
        // L'inversion de dépendance se matérialise ici
        { provide: ORDER_REPOSITORY, useClass: HttpOrderRepository },
    ],
};

// En test, on remplace simplement par un fake :
// { provide: ORDER_REPOSITORY, useClass: InMemoryOrderRepository }
Gain pratique : changer de backend (REST → GraphQL → tRPC) ne modifie qu'un seul fichier dans infrastructure/. Le domaine, les use cases et les composants restent intacts.

Domain-Driven Design : modéliser le métier avant le code

Clean Architecture organise les couches techniques. DDD remplit la couche Domain avec des concepts métier rigoureux. Les deux travaillent ensemble : Clean est structurel, DDD est sémantique.

Les blocs de base DDD

Concept Définition Exemple e-commerce
Entity Identité unique stable dans le temps Order, Customer
Value Object Immutable, défini par ses attributs, sans identité Money, Address, Email
Aggregate Groupe d'objets traité comme une unité, racine = Aggregate Root Order (root) + OrderItem
Repository Persistance d'un Aggregate Root OrderRepository
Domain Event Fait métier passé qui intéresse d'autres contextes OrderPlaced, PaymentFailed
Bounded Context Frontière explicite d'un modèle cohérent Catalog, Ordering, Shipping, Billing

Value Object — toujours immutable

// domain/value-objects/money.vo.ts
export class Money {
    private constructor(
        public readonly amount: number,
        public readonly currency: string,
    ) {
        if (amount < 0) throw new Error('Money cannot be negative');
        if (!/^[A-Z]{3}$/.test(currency)) throw new Error('Invalid ISO currency');
    }

    static of(amount: number, currency: string): Money {
        return new Money(Math.round(amount * 100) / 100, currency);
    }

    static zero(currency: string): Money {
        return new Money(0, currency);
    }

    add(other: Money): Money {
        // Règle métier explicite : on ne peut pas additionner EUR + USD
        if (this.currency !== other.currency) {
            throw new Error(`Currency mismatch: ${this.currency} vs ${other.currency}`);
        }
        return Money.of(this.amount + other.amount, this.currency);
    }

    multiply(factor: number): Money {
        return Money.of(this.amount * factor, this.currency);
    }

    equals(other: Money): boolean {
        return this.amount === other.amount && this.currency === other.currency;
    }
}

Aggregate Root — encapsule les invariants

// domain/aggregates/cart.aggregate.ts
// Le Cart est la racine. On ne manipule jamais CartItem directement de l'extérieur.
export class Cart {
    private _items: CartItem[] = [];
    private _events: DomainEvent[] = [];

    private constructor(public readonly id: string, public readonly customerId: string) {}

    static create(customerId: string): Cart {
        return new Cart(crypto.randomUUID(), customerId);
    }

    addItem(productId: string, unitPrice: Money, quantity: number): void {
        if (quantity <= 0) throw new Error('Quantity must be positive');
        if (this._items.length >= 100) {
            throw new Error('Cart cannot exceed 100 items'); // Invariant métier
        }
        const existing = this._items.find(i => i.productId === productId);
        if (existing) {
            existing.increaseQuantity(quantity);
        } else {
            this._items.push(new CartItem(productId, unitPrice, quantity));
        }
        this._events.push(new ItemAddedToCart(this.id, productId, quantity));
    }

    get total(): Money {
        return this._items.reduce(
            (sum, item) => sum.add(item.subtotal),
            Money.zero('EUR'),
        );
    }

    pullEvents(): DomainEvent[] {
        const events = [...this._events];
        this._events = [];
        return events;
    }
}

Bounded Contexts — découpage stratégique

Le mot "client" ne veut pas dire la même chose dans Marketing (prospect avec score CRM), dans Ordering (acheteur avec adresse de livraison), et dans Billing (entité légale avec SIRET). Chacun a son propre modèle.

Règle d'or DDD : ne jamais partager une entité entre deux bounded contexts. Communiquer entre contextes se fait par événements ou API publiques bien définies, jamais par référence directe en mémoire ou jointures SQL transversales.

Identifier ses bounded contexts en 5 étapes :

  • ✅ Lister les sous-domaines métier (catalog, ordering, shipping, billing, etc.)
  • ✅ Pour chaque sous-domaine, identifier les experts métier (un par contexte)
  • ✅ Cartographier le vocabulaire (ubiquitous language) propre à chaque contexte
  • ✅ Dessiner un Context Map : qui appelle qui, par quel canal
  • ✅ Définir des relations explicites : Customer-Supplier, Conformist, Anti-Corruption Layer

DDD côté Node.js / NestJS

// apps/ordering-api/src/ordering/application/place-order.handler.ts
import { CommandHandler, ICommandHandler, EventBus } from '@nestjs/cqrs';
import { Cart } from '../domain/aggregates/cart.aggregate';
import { OrderPlaced } from '../domain/events/order-placed.event';

@CommandHandler(PlaceOrderCommand)
export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> {
    constructor(
        private readonly cartRepo: CartRepository,
        private readonly orderRepo: OrderRepository,
        private readonly eventBus: EventBus,
    ) {}

    async execute(cmd: PlaceOrderCommand): Promise<{ orderId: string }> {
        const cart = await this.cartRepo.findById(cmd.cartId);
        if (!cart) throw new NotFoundException('Cart not found');

        // Toute la logique métier vit dans l'aggregate
        const order = cart.checkout();
        await this.orderRepo.save(order);

        // Publication des événements métier
        for (const event of order.pullEvents()) {
            this.eventBus.publish(event);
        }
        return { orderId: order.id };
    }
}

Monorepos avec Nx : organiser Angular + Node.js

Une fois SOLID, Clean et DDD posés, la question devient : comment organiser le code à l'échelle de plusieurs apps et équipes ? Le monorepo répond en regroupant front, back, mobile et libs partagées dans un seul dépôt versionné.

Nx (par Nrwl, racheté par GitLab en 2025) est l'outil de référence pour les stacks TypeScript. Il offre :

  • Generators : créer apps et libs avec des templates cohérents (nx g @nx/angular:app)
  • Executors : build, test, lint normalisés par projet
  • Dependency Graph : visualiser les dépendances entre projets (nx graph)
  • Affected : ne rebuilder/retester que ce qui a changé (nx affected -t build)
  • Computation Caching : local + distant via Nx Cloud, réduit le CI de 70 à 90 %
  • Module Boundaries : règles ESLint pour interdire les imports interdits entre couches

Création d'un workspace Nx fullstack

# Initialiser un monorepo Nx vierge
npx create-nx-workspace@latest my-platform --preset=apps

cd my-platform

# Ajouter le plugin Angular
npm install -D @nx/angular

# Générer une app Angular (frontend boutique)
nx g @nx/angular:app shop --standalone --routing --style=scss

# Ajouter le plugin Nest pour le backend
npm install -D @nx/nest

# Générer une API Nest (backend ordering)
nx g @nx/nest:app ordering-api

# Créer des libs partagées (DDD : un sous-domaine = une lib)
nx g @nx/js:lib shared-domain --directory=libs/shared/domain
nx g @nx/js:lib ordering-domain --directory=libs/ordering/domain
nx g @nx/angular:lib ordering-feature --directory=libs/ordering/feature
nx g @nx/js:lib ordering-data-access --directory=libs/ordering/data-access

Structure de monorepo recommandée

my-platform/
├── apps/
│   ├── shop/                    # Angular front client
│   ├── back-office/             # Angular front admin
│   ├── ordering-api/            # Nest backend ordering
│   ├── catalog-api/             # Nest backend catalog
│   └── billing-api/             # Nest backend billing
├── libs/
│   ├── shared/
│   │   ├── domain/              # Value objects partagés (Money, Address)
│   │   ├── ui/                  # Composants Angular réutilisables
│   │   └── utils/               # Helpers TypeScript pure
│   ├── ordering/
│   │   ├── domain/              # Entités, ports, use cases
│   │   ├── data-access/         # Repositories HTTP/Postgres
│   │   ├── feature/             # Composants smart (pages)
│   │   └── ui/                  # Composants présentationnels
│   └── catalog/
│       └── ... (même découpage)
├── nx.json
├── tsconfig.base.json
└── package.json

Module boundaries — règle architecturale par ESLint

// .eslintrc.json — bloquer les imports interdits entre couches
{
    "overrides": [
        {
            "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
            "rules": {
                "@nx/enforce-module-boundaries": [
                    "error",
                    {
                        "enforceBuildableLibDependency": true,
                        "allow": [],
                        "depConstraints": [
                            {
                                "sourceTag": "type:feature",
                                "onlyDependOnLibsWithTags": [
                                    "type:domain", "type:data-access", "type:ui", "type:shared"
                                ]
                            },
                            {
                                "sourceTag": "type:domain",
                                "onlyDependOnLibsWithTags": ["type:domain", "type:shared"]
                            },
                            {
                                "sourceTag": "scope:ordering",
                                "onlyDependOnLibsWithTags": ["scope:ordering", "scope:shared"]
                            }
                        ]
                    }
                ]
            }
        }
    ]
}
Conséquence pratique : un dev qui essaie d'importer libs/billing/domain depuis libs/ordering/feature reçoit une erreur ESLint au build. Les bounded contexts DDD deviennent physiquement impossibles à violer.

Commandes Nx au quotidien

# Lancer le front shop en dev
nx serve shop

# Lancer le back ordering-api en dev
nx serve ordering-api

# Build une seule app
nx build shop

# Tester seulement ce qui a changé depuis main
nx affected -t test --base=main

# Builder en parallèle tout ce qui est impacté par les changements
nx affected -t build --parallel=4

# Visualiser le graphe de dépendances
nx graph

# Lint sur tout le workspace
nx run-many -t lint

# Voir le cache hit (avec Nx Cloud)
nx affected -t test --base=main --verbose

Sur un workspace de 80 projets, nx affected -t test exécute en moyenne 7 fois moins de tests qu'un jest global, car seuls les projets impactés par le diff Git sont retenus.

Turborepo : l'alternative légère et rapide

Turborepo (par Vercel) est plus simple que Nx. Il ne génère pas de code, n'impose pas de plugins par framework, et se concentre sur deux choses : orchestration de tâches et cache distant. Idéal pour les stacks hétérogènes (Next.js + Vite + packages npm pure) ou pour les équipes qui préfèrent garder leurs scripts npm existants.

Initialisation Turborepo

# Créer un monorepo Turborepo
npx create-turbo@latest my-platform

cd my-platform

# Installer les workspaces (npm/pnpm/yarn — ici pnpm recommandé)
pnpm install

# Lancer toutes les tâches "dev" en parallèle
pnpm turbo dev

# Build avec cache
pnpm turbo build

# Filtre : ne builder qu'un package
pnpm turbo build --filter=shop

Configuration turbo.json

{
    "$schema": "https://turbo.build/schema.json",
    "tasks": {
        "build": {
            "dependsOn": ["^build"],
            "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
        },
        "test": {
            "dependsOn": ["build"],
            "outputs": ["coverage/**"]
        },
        "lint": {
            "outputs": []
        },
        "dev": {
            "cache": false,
            "persistent": true
        }
    }
}

Nx vs Turborepo : décider en 30 secondes

Critère Nx Turborepo
Génération de code (apps, libs) Riche Aucune
Intégration Angular native Best-in-class Manuelle
Cache distant Nx Cloud Vercel Remote Cache
Dependency graph & affected Avancé Basique
Module boundaries (ESLint) Plugin officiel À configurer
Courbe d'apprentissage Moyenne (concepts riches) Très faible
Idéal pour Angular, Nest, gros workspaces Next.js, packages npm, polyglotte
Recommandation 2026 : Angular + Nest = Nx sans hésiter. Next.js + Vite + libs npm = Turborepo plus simple. Au-delà de 50 projets, Nx s'impose pour la gouvernance même sur stack Next.js.

Micro-frontends : découper un monolithe Angular

Le micro-frontend est l'application du principe des microservices au front-end : chaque équipe livre, teste et déploie indépendamment une portion de l'UI. Mais c'est aussi la décision architecturale la plus coûteuse — elle ne se justifie qu'au-delà d'un certain seuil.

Quand introduire des micro-frontends ?

Critères cumulatifs (au moins 3 sur 5) :

  • ✅ Plus de 4 équipes feature livrent en parallèle sur la même app
  • ✅ Le monolithe dépasse 500 000 lignes de code TypeScript
  • ✅ Le build complet dépasse 8 minutes en CI
  • ✅ Les sous-domaines métier ont des cycles de release différents
  • ✅ Au moins une équipe veut une stack différente (React au lieu d'Angular)
Anti-pattern fréquent : introduire des micro-frontends pour "moderniser" alors qu'un monorepo Nx avec libs bien découpées résoudrait 90 % des problèmes à 10 % du coût. Le MFE n'est pas une amélioration technique, c'est une réponse organisationnelle.

Approche 1 — Module Federation (Webpack/Native)

Module Federation, intégré dans Angular via @angular-architects/module-federation, permet de charger des bouts d'application au runtime depuis des URLs distantes. Chaque remote est buildé et déployé séparément.

# Installer le plugin Angular
ng add @angular-architects/module-federation@latest --project shell --port 4200
ng add @angular-architects/module-federation@latest --project ordering --port 4201
ng add @angular-architects/module-federation@latest --project catalog --port 4202
// shell/webpack.config.js — l'hôte qui charge les remotes
const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');

module.exports = withModuleFederationPlugin({
    remotes: {
        // À l'exécution, le shell télécharge ce fichier
        'ordering': 'http://localhost:4201/remoteEntry.js',
        'catalog':  'http://localhost:4202/remoteEntry.js',
    },
    shared: {
        ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
    },
});
// ordering/webpack.config.js — le remote qui expose ses routes
const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');

module.exports = withModuleFederationPlugin({
    name: 'ordering',
    exposes: {
        './Routes': './apps/ordering/src/app/ordering.routes.ts',
    },
    shared: {
        ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
    },
});
// shell/src/app/app.routes.ts — chargement lazy des remotes
import { Routes } from '@angular/router';
import { loadRemoteModule } from '@angular-architects/module-federation';

export const routes: Routes = [
    {
        path: 'orders',
        // Chargement à la demande depuis l'URL du remote
        loadChildren: () => loadRemoteModule({
            type: 'module',
            remoteEntry: 'http://localhost:4201/remoteEntry.js',
            exposedModule: './Routes',
        }).then(m => m.ORDERING_ROUTES),
    },
    {
        path: 'catalog',
        loadChildren: () => loadRemoteModule({
            type: 'module',
            remoteEntry: 'http://localhost:4202/remoteEntry.js',
            exposedModule: './Routes',
        }).then(m => m.CATALOG_ROUTES),
    },
];

Approche 2 — Web Components + Single-SPA

Pour un environnement vraiment hétérogène (Angular + React + Vue dans la même page), Single-SPA orchestre des applications indépendantes qui s'enregistrent comme des Web Components.

// root-config.js — orchestrateur Single-SPA
import { registerApplication, start } from 'single-spa';

registerApplication({
    name: '@company/ordering-angular',
    app: () => System.import('@company/ordering-angular'),
    activeWhen: ['/orders'],
});

registerApplication({
    name: '@company/catalog-react',
    app: () => System.import('@company/catalog-react'),
    activeWhen: ['/catalog'],
});

start();

Communication entre micro-frontends

Besoin Solution Recommandation
Notifier un autre MFE d'un événement CustomEvent global ✅ Léger et standard
Partager l'utilisateur authentifié Lib partagée (singleton) ou cookie ✅ Lib auth avec shareSingleton
Partager du state lourd (panier) Store global (Redux/NgRx) ⚠️ Réintroduit du couplage — éviter
Navigation entre MFE Router central du shell ✅ Le shell garde la responsabilité du routing
Règle de découplage : chaque MFE doit pouvoir tourner seul en mode standalone, sans le shell. Sinon, ce n'est pas un micro-frontend, c'est un module fragmenté.

Synthèse : combiner les approches en production

Les quatre approches ne sont pas exclusives — elles forment une pyramide d'adoption progressive. Voici l'ordre d'introduction recommandé sur un projet réel.

Phase Quand l'introduire Approche Effort
1 Dès le premier composant SOLID (surtout SRP + DIP) Faible — discipline de codage
2 Quand le projet dépasse 10 features Clean Architecture (4 couches) Moyen — refonte structure dossiers
3 Quand le métier devient complexe DDD (bounded contexts, aggregates) Élevé — ateliers Event Storming
4 2+ apps ou 3+ équipes Monorepo Nx ou Turborepo Moyen — migration repos existants
5 5+ équipes, 500k LOC, cycles distincts Micro-frontends Module Federation Très élevé — infra + gouvernance

Exemple combiné : architecture cible d'une plateforme e-commerce

my-shop-platform/                  # Monorepo Nx
├── apps/
│   ├── shop-shell/                # Hôte Module Federation (Angular)
│   ├── ordering-mfe/              # Remote micro-frontend (Angular)
│   ├── catalog-mfe/               # Remote micro-frontend (Angular)
│   ├── ordering-api/              # Backend NestJS (DDD bounded context)
│   ├── catalog-api/               # Backend NestJS (DDD bounded context)
│   └── billing-api/               # Backend NestJS (DDD bounded context)
├── libs/
│   ├── shared/
│   │   ├── domain/                # Money, Address (Value Objects communs)
│   │   ├── ui/                    # Boutons, modales (Angular Material)
│   │   └── auth/                  # Auth singleton partagée entre MFE
│   ├── ordering/
│   │   ├── domain/                # Cart, Order, OrderItem (Aggregates)
│   │   ├── application/           # PlaceOrderUseCase
│   │   ├── data-access/           # HttpOrderRepository (Adapter)
│   │   ├── feature/               # Pages Smart Components
│   │   └── ui/                    # Composants présentationnels
│   ├── catalog/
│   │   └── ... (même structure)
│   └── billing/
│       └── ... (même structure)

Dans cette architecture :

  • SOLID guide chaque service, composant et use case (granularité fine)
  • Clean Architecture structure chaque lib (domain/application/data-access/feature)
  • DDD définit le découpage en bounded contexts (ordering, catalog, billing)
  • Nx centralise la gouvernance, le cache CI et les module boundaries
  • Module Federation permet aux équipes Ordering et Catalog de déployer indépendamment
Métrique cible 2026 : sur une plateforme de cette taille bien architecturée, le temps moyen de livraison d'une fonctionnalité (lead time) passe typiquement de 18 jours à 4 jours, et le taux d'incidents en production divise par 3. Les chiffres viennent de la dernière étude DORA appliquée à des organisations ayant adopté cette stack complète.

Erreurs à éviter

  • Big bang refactor : ne jamais migrer toute une codebase en une fois. Choisir un bounded context pilote, le migrer en Clean Architecture, mesurer, étendre.
  • Over-engineering : un MVP de 5 000 lignes n'a pas besoin de bounded contexts ni de MFE. SOLID + structure feature-based suffit.
  • Bounded contexts trop granulaires : 12 contextes pour 30 endpoints crée plus de couplage qu'un seul contexte bien découpé.
  • Micro-frontends pour des raisons techniques : si c'est juste pour "réduire le bundle", utilisez du loadComponent() Angular natif.
  • Repository qui fuit dans le domain : un Repository qui renvoie des entités ORM avec des décorateurs @Entity pollue le domaine. Toujours convertir vers Domain Entities pures.
Pour aller plus loin : consultez le livre Domain-Driven Design d'Eric Evans (référence absolue), Clean Architecture de Robert C. Martin, et la documentation officielle Nx (nx.dev) pour les patterns avancés Angular + NestJS monorepo.

Partager