Organisez vos apps Angular en bounded contexts DDD avec sheriff, Nx tags et architecture en oignon : domain, data-access, feature et ui isoles par metier.
Pourquoi le DDD côté frontend ?
Le Domain-Driven Design a longtemps été associé exclusivement au backend, où la modélisation des entités métier semblait évidente. Pourtant, dès qu'une application Angular dépasse 50 composants ou 3 équipes, les mêmes problèmes apparaissent : couplage involontaire entre fonctionnalités, dépendances circulaires, difficulté à faire évoluer un module sans casser les autres.
Le DDD frontend ne consiste pas à recopier les concepts backend (agrégats, repositories, value objects). Il consiste à organiser le code par domaine métier plutôt que par couche technique, et à imposer des frontières strictes entre ces domaines. C'est une approche pragmatique, particulièrement adaptée à Angular grâce à son système d'injection et de modules (ou libraries Nx).
Approche classique vs DDD
| Aspect | Organisation par couche technique | Organisation DDD (par domaine) |
|---|---|---|
| Structure | components/, services/, models/, pipes/ | catalog/, billing/, shipping/, shared/ |
| Couplage | Élevé — tous services dans le même dossier | Faible — frontières typées |
| Onboarding | Faut connaître toute la base | Un dev = un domaine = 1 semaine |
| Scaling équipe | Conflits Git fréquents | 1 équipe = 1 bounded context |
| Migration framework | Tout migrer en un bloc | Module par module |
Vocabulaire DDD adapté à Angular
Le DDD vient d'un livre dense (Eric Evans, 2003) avec son propre jargon. Voici la traduction concrète pour un développeur Angular.
Bounded Context
Un bounded context est un sous-ensemble cohérent du domaine métier, avec son propre langage et ses propres règles. En Angular, c'est généralement une feature library Nx isolée. Exemple : « catalog » ne sait rien de « billing », et inversement.
Ubiquitous Language
Chaque bounded context a son vocabulaire propre. Le mot « commande » dans « catalog » désigne un panier ; dans « shipping », c'est une livraison. Au lieu d'avoir un modèle Order partagé, chaque context a son CatalogOrder et son ShippingOrder.
Domain (couche métier)
Le code métier pur, sans dépendance Angular ni HTTP. Types, classes, fonctions de validation, machines à état. Testable en isolation, transposable vers un autre framework si besoin.
Application (couche use cases)
Les cas d'usage applicatifs : « ajouter au panier », « valider une commande ». Cette couche orchestre le domain et déclenche les effets de bord (HTTP, state, navigation).
Infrastructure / API
L'adapter vers le backend : HttpClient, mappers DTO→Domain, gestion d'erreurs réseau. Tout ce qui pourrait changer en cas de changement d'API REST → GraphQL → tRPC.
UI / Feature / Shell
Les composants Angular proprement dits, qui consomment uniquement la couche application via inject(). Ils ne connaissent pas HttpClient ni les routes API.
Découper l'application en bounded contexts
L'erreur la plus courante est de copier l'organisation backend. Un bounded context frontend n'est pas forcément aligné avec une table SQL ou un microservice. Il correspond à une cohérence fonctionnelle du point de vue utilisateur.
Workflow de découpage : Event Storming léger
Réunissez les développeurs front, le PO et un utilisateur. Sur un mural virtuel :
- Lister les événements métier (« le panier est validé », « le paiement est accepté », « l'expédition est lancée »)
- Grouper les événements qui partagent le même vocabulaire et les mêmes acteurs
- Chaque groupe = un bounded context candidat
- Vérifier l'autonomie : peut-on développer ce groupe sans dépendre du voisin ?
Exemple : e-commerce typique
// Structure conseillée d'un monorepo Nx pour un e-commerce
my-shop/
├── apps/
│ ├── shop/ // app principale (shell + routes)
│ └── admin/ // app admin séparée si besoin
├── libs/
│ ├── catalog/ // BOUNDED CONTEXT "Catalogue"
│ │ ├── domain/ // entités produit, panier (logique pure)
│ │ ├── data-access/ // ProductApi, CartApi (HTTP)
│ │ ├── feature-search/ // page recherche
│ │ ├── feature-product/ // page produit
│ │ └── ui/ // composants présentationnels (ProductCard)
│ ├── billing/ // BOUNDED CONTEXT "Facturation"
│ │ ├── domain/ // Invoice, Payment, VatCalculator
│ │ ├── data-access/ // BillingApi, PaymentApi (Stripe)
│ │ ├── feature-checkout/ // tunnel de paiement
│ │ └── ui/
│ ├── shipping/ // BOUNDED CONTEXT "Livraison"
│ │ ├── domain/ // Address, Shipment, Carrier
│ │ ├── data-access/
│ │ ├── feature-tracking/
│ │ └── ui/
│ └── shared/ // utils techniques transversaux UNIQUEMENT
│ ├── ui-design-system/ // boutons, modales — pas de métier
│ └── util-formatting/ // date, currency
libs/shared ne contient JAMAIS de logique métier. C'est uniquement du technique réutilisable (formatage, UI design system, helpers HTTP). Sinon vous recréez un god-module sous un autre nom.
Les 4 couches d'un bounded context
Chaque bounded context suit la même structure interne en 4 couches concentriques. Cette homogénéité accélère drastiquement la lecture du code et l'onboarding.
1. Domain — la logique métier pure
// libs/catalog/domain/src/lib/cart.ts
// Aucune dépendance Angular, RxJS ou HTTP. Pure TypeScript.
export interface CartItem {
readonly productId: string;
readonly name: string;
readonly unitPriceCents: number;
readonly quantity: number;
}
export interface Cart {
readonly items: readonly CartItem[];
readonly currency: 'EUR' | 'USD';
}
// Règle métier : calculer le total — fonction pure, testable trivialement
export function computeCartTotal(cart: Cart): number {
return cart.items.reduce((sum, item) => sum + item.unitPriceCents * item.quantity, 0);
}
// Règle métier : ajouter un produit en respectant la limite de quantité
export function addToCart(cart: Cart, item: CartItem): Cart {
if (item.quantity < 1 || item.quantity > 99) {
throw new Error('Quantity must be between 1 and 99');
}
const existing = cart.items.find(i => i.productId === item.productId);
const items = existing
? cart.items.map(i => i.productId === item.productId
? { ...i, quantity: Math.min(99, i.quantity + item.quantity) }
: i)
: [...cart.items, item];
return { ...cart, items };
}
2. Data-access — l'adapter HTTP
// libs/catalog/data-access/src/lib/product.api.ts
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { CartItem } from '@my-shop/catalog/domain';
// DTO côté API — peut différer du modèle Domain
interface ProductDto {
id: string;
label: string;
price_cents: number; // snake_case côté backend
}
@Injectable({ providedIn: 'root' })
export class ProductApi {
private http = inject(HttpClient);
async searchAsCartItem(query: string): Promise<CartItem[]> {
const dtos = await this.http
.get<ProductDto[]>('/api/products', { params: { q: query } })
.toPromise() ?? [];
// Mapping DTO → Domain : isole le frontend des changements d'API
return dtos.map(d => ({
productId: d.id,
name: d.label,
unitPriceCents: d.price_cents,
quantity: 1,
}));
}
}
3. Feature — orchestration use case
// libs/catalog/feature-product/src/lib/product-page.component.ts
import { Component, inject, signal } from '@angular/core';
import { ProductApi } from '@my-shop/catalog/data-access';
import { CartStore } from '@my-shop/catalog/feature-cart';
import { addToCart, type CartItem } from '@my-shop/catalog/domain';
@Component({
selector: 'catalog-product-page',
standalone: true,
template: `... ng-content ...`,
})
export class ProductPageComponent {
private api = inject(ProductApi);
private cart = inject(CartStore);
products = signal<CartItem[]>([]);
async search(q: string) {
// Cette méthode orchestre : appel API + transformation domain + update store
const results = await this.api.searchAsCartItem(q);
this.products.set(results);
}
addOne(item: CartItem) {
// Le composant ne calcule PAS lui-même — il délègue au domain
this.cart.update(current => addToCart(current, item));
}
}
4. UI — composants présentationnels
// libs/catalog/ui/src/lib/product-card.component.ts
import { Component, input, output } from '@angular/core';
@Component({
selector: 'catalog-product-card',
standalone: true,
template: `
<article>
<h3>{{ name() }}</h3>
<p>{{ priceCents() / 100 | currency:'EUR' }}</p>
<button (click)="add.emit()">Ajouter au panier</button>
</article>
`,
})
export class ProductCardComponent {
// 100% présentationnel : inputs + outputs uniquement, aucun service
name = input.required<string>();
priceCents = input.required<number>();
add = output<void>();
}
inject(SomeService). S'il a besoin d'un service, c'est qu'il est en réalité un feature, pas une UI brique.
Sheriff : imposer les règles d'architecture
Le problème classique avec une architecture DDD : sans outillage, les développeurs finissent toujours par enfreindre les règles « juste pour cette fois ». Sheriff est une librairie open source par Rainer Hahnekamp qui transforme vos règles en erreurs ESLint et build.
// Installation
npm install --save-dev @softarc/sheriff-core eslint-plugin-sheriff
// sheriff.config.ts — déclarer les frontières
import { sameTag, SheriffConfig } from '@softarc/sheriff-core';
export const config: SheriffConfig = {
version: 1,
tagging: {
// Tague chaque dossier avec son type et son context
'libs/<domain>/domain': ['type:domain', 'context:<domain>'],
'libs/<domain>/data-access': ['type:data-access', 'context:<domain>'],
'libs/<domain>/feature-*': ['type:feature', 'context:<domain>'],
'libs/<domain>/ui': ['type:ui', 'context:<domain>'],
'libs/shared/<name>': ['type:shared'],
},
depRules: {
// root = apps : peut tout importer
root: 'noTag',
// Domain ne peut RIEN importer (pure logic)
'type:domain': [],
// Data-access peut importer domain du même context UNIQUEMENT
'type:data-access': [sameTag('context'), 'type:domain'],
// Feature peut importer data-access, domain, ui du même context + shared
'type:feature': [sameTag('context'), 'type:data-access', 'type:domain', 'type:ui', 'type:shared'],
// UI peut UNIQUEMENT importer domain (pour les types) — JAMAIS data-access
'type:ui': ['type:domain'],
// Shared : technique pur, peut importer entre soi mais pas de métier
'type:shared': ['type:shared'],
},
};
Désormais, si un développeur écrit import { ProductApi } from '@my-shop/catalog/data-access' dans un composant UI, le lint échoue. Le build CI échoue aussi. Plus aucun moyen de tricher.
Communication inter-contexts sans couplage
Si scope:catalog ne peut pas importer scope:billing, comment le panier déclenche-t-il le checkout ? La réponse DDD : par événements de domaine ou via le shell.
Stratégie 1 : événements via un EventBus
// libs/shared/util-events/src/lib/event-bus.service.ts
import { Injectable } from '@angular/core';
import { Subject, Observable, filter } from 'rxjs';
// Type discriminant — chaque bounded context déclare ses propres événements
export interface DomainEvent {
readonly type: string;
readonly payload: unknown;
}
@Injectable({ providedIn: 'root' })
export class EventBus {
private bus$ = new Subject<DomainEvent>();
publish(event: DomainEvent): void { this.bus$.next(event); }
on<T extends DomainEvent>(type: T['type']): Observable<T> {
return this.bus$.pipe(filter((e): e is T => e.type === type));
}
}
// libs/catalog/feature-cart/src/lib/cart.store.ts
import { inject, Injectable, signal } from '@angular/core';
import { EventBus } from '@my-shop/shared/util-events';
import { Cart } from '@my-shop/catalog/domain';
interface CartValidatedEvent {
type: 'cart.validated';
payload: { cartId: string; totalCents: number };
}
@Injectable({ providedIn: 'root' })
export class CartStore {
private bus = inject(EventBus);
cart = signal<Cart>({ items: [], currency: 'EUR' });
validate(): void {
// Publie un événement SANS connaître billing — couplage zéro
const total = this.cart().items.reduce((s, i) => s + i.unitPriceCents * i.quantity, 0);
this.bus.publish({
type: 'cart.validated',
payload: { cartId: crypto.randomUUID(), totalCents: total },
} satisfies CartValidatedEvent);
}
}
// libs/billing/feature-checkout/src/lib/checkout.store.ts
@Injectable({ providedIn: 'root' })
export class CheckoutStore {
private bus = inject(EventBus);
constructor() {
// S'abonne sans dépendre de catalog/feature-cart
this.bus.on<CartValidatedEvent>('cart.validated').subscribe(({ payload }) => {
this.startCheckout(payload.cartId, payload.totalCents);
});
}
private startCheckout(cartId: string, totalCents: number) { /* ... */ }
}
Stratégie 2 : orchestration par le shell
Pour les workflows critiques (paiement, transactions), une orchestration explicite par l'app shell est souvent préférable. Le shell connaît TOUS les contexts ; les contexts ne connaissent que le shell.
Exemple complet : e-commerce DDD
Pour visualiser comment tout s'assemble, voici la séquence complète d'une commande dans notre architecture DDD.
// 1. UI : l'utilisateur clique "Ajouter au panier" dans catalog/ui
// catalog-product-card émet l'output `add` → géré par feature-product
// 2. FEATURE : feature-product appelle catalog/data-access
const items = await this.productApi.searchAsCartItem(query);
// 3. DOMAIN : feature-product utilise les fonctions pures du domain
const updatedCart = addToCart(this.cart.cart(), items[0]);
this.cart.cart.set(updatedCart);
// 4. EVENT : à la validation, feature-cart émet cart.validated via EventBus
this.bus.publish({ type: 'cart.validated', payload: { cartId, totalCents } });
// 5. BILLING : billing/feature-checkout s'abonne et démarre le tunnel paiement
this.bus.on<CartValidatedEvent>('cart.validated').subscribe(...);
// 6. SHIPPING : à la confirmation paiement, shipping/feature-tracking s'active
this.bus.on<PaymentSucceededEvent>('payment.succeeded').subscribe(...);
- Aucun import cross-context (Sheriff/Nx vous protège)
- Domain n'importe ni Angular ni RxJS
- UI components n'ont pas de
inject(SomeApi) - Tests unitaires des fonctions domain sans
TestBed - Communication inter-context via EventBus ou shell
- Pas de
sharedqui contient du métier - Mappers DTO→Domain présents dans data-access
Conclusion et chemin d'adoption
Le DDD frontend n'est pas une mode théorique : c'est une réponse concrète à la complexité croissante des applications Angular d'entreprise. Sur un projet de 5+ développeurs ou 50+ composants, l'investissement initial (refactor en bounded contexts, mise en place de Sheriff ou Nx tags) est rentabilisé en quelques mois.
Le chemin d'adoption pragmatique :
- Identifier 2-3 bounded contexts évidents via un event storming court
- Migrer un seul context en feature library Nx avec les 4 couches
- Ajouter Sheriff ou Nx boundaries sur ce premier context uniquement
- Valider avec l'équipe que la productivité est maintenue
- Étendre progressivement aux autres contexts, mois par mois