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.
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.
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;
}
}
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 }
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.
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"]
}
]
}
]
}
}
]
}
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 |
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)
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 |
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
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
@Entitypollue le domaine. Toujours convertir vers Domain Entities pures.