Front-end angularforall.com

- Angular DDD Frontend : bounded contexts purs

Angular Domain-Driven-Design Ddd Bounded-Contexts Sheriff Nx Architecture Feature-Libraries Clean-Architecture Enterprise Monorepo Event-Bus Ubiquitous-Language Boundaries
Angular DDD Frontend : bounded contexts purs

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).

Symptômes qui doivent vous alerter : un changement dans le module « factures » casse les tests du module « catalogue ». Le nouveau développeur met 3 semaines à comprendre où ajouter une feature. La codebase ressemble à un plat de spaghetti. Tous ces signes pointent vers un manque de bounded contexts clairs.

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.

Inversion de dépendances : UI dépend d'Application, Application dépend de Domain, Infrastructure dépend de Domain. Mais Domain ne dépend de RIEN. C'est le cœur de l'architecture en oignon (Clean Architecture).

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 :

  1. Lister les événements métier (« le panier est validé », « le paiement est accepté », « l'expédition est lancée »)
  2. Grouper les événements qui partagent le même vocabulaire et les mêmes acteurs
  3. Chaque groupe = un bounded context candidat
  4. 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
Règle d'or : 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>();
}
Principe : un composant UI ne devrait jamais faire 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.

Cas réel : sur un projet bancaire de 300+ composants, l'introduction de Sheriff a permis d'identifier 47 violations cachées d'architecture en 1 commit. Le refactor a pris 2 semaines mais les régressions ont disparu pour les trimestres suivants.

Nx tags et boundaries automatiques

Si vous utilisez Nx (recommandé pour DDD à grande échelle), vous pouvez compléter Sheriff avec les Nx tags et la règle ESLint @nx/enforce-module-boundaries. C'est la même idée mais intégrée nativement à Nx.

// libs/catalog/domain/project.json
{
    "name": "catalog-domain",
    "tags": ["scope:catalog", "type:domain"]
}

// libs/catalog/feature-product/project.json
{
    "name": "catalog-feature-product",
    "tags": ["scope:catalog", "type:feature"]
}
// .eslintrc.json à la racine du workspace
{
    "rules": {
        "@nx/enforce-module-boundaries": ["error", {
            "depConstraints": [
                {
                    "sourceTag": "type:feature",
                    "onlyDependOnLibsWithTags": ["type:feature", "type:data-access", "type:domain", "type:ui", "scope:shared"]
                },
                {
                    "sourceTag": "type:ui",
                    "onlyDependOnLibsWithTags": ["type:domain", "scope:shared"]
                },
                {
                    "sourceTag": "type:domain",
                    "onlyDependOnLibsWithTags": []
                },
                {
                    "sourceTag": "scope:catalog",
                    "onlyDependOnLibsWithTags": ["scope:catalog", "scope:shared"]
                },
                {
                    "sourceTag": "scope:billing",
                    "onlyDependOnLibsWithTags": ["scope:billing", "scope:shared"]
                }
            ]
        }]
    }
}

Avec ces règles, scope:catalog ne peut PAS importer scope:billing. La frontière entre bounded contexts devient absolue.

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.

EventBus vs orchestration shell : l'EventBus est idéal pour des notifications asynchrones (analytics, logs, side-effects). L'orchestration shell est préférable pour les workflows critiques où l'ordre des étapes doit être garanti.

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(...);
Vérifications à faire dans un PR DDD :
  • 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 shared qui 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 :

  1. Identifier 2-3 bounded contexts évidents via un event storming court
  2. Migrer un seul context en feature library Nx avec les 4 couches
  3. Ajouter Sheriff ou Nx boundaries sur ce premier context uniquement
  4. Valider avec l'équipe que la productivité est maintenue
  5. Étendre progressivement aux autres contexts, mois par mois
Pour aller plus loin : les Angular Architects ont publié un livre gratuit « Modern Angular Enterprise Architectures » (PDF en ligne). Manfred Steyer y détaille les patterns avec sheriff, NgRx Signal Store et Module Federation. C'est la référence pour passer du DDD théorique au DDD opérationnel.

Partager