Aller au contenu

Domain-Driven Design

Introduction

Le Domain-Driven Design (DDD) est une manière de concevoir un logiciel en plaçant le métier au centre des décisions techniques.

Chez Feelbat, le DDD guide deux choses :

  • le découpage des services (design stratégique), déjà décrit dans la page Bounded Contexts ;
  • la modélisation interne d'un service (design tactique), qui est le sujet de cette page.

Cette page se concentre sur les briques tactiques : les objets qui composent le domaine d'un service et les règles qu'ils garantissent.

Objectif

À la fin de cette page, vous comprendrez :

  • la différence entre design stratégique et design tactique ;
  • ce qu'est un Value Object, une Entity et un Aggregate ;
  • comment les invariants métier sont protégés dans le code Feelbat ;
  • le rôle d'un repository et d'un service applicatif ;
  • comment le domaine communique avec l'extérieur.

Deux niveaux de DDD

Le DDD se lit à deux échelles complémentaires.

Niveau Question posée Où c'est traité
Stratégique Quels sont les domaines métier et leurs frontières ? Bounded Contexts
Tactique Comment modéliser un domaine à l'intérieur d'un service ? Cette page

Le design tactique s'appuie sur l'architecture hexagonale : les briques présentées ici vivent toutes dans la couche domaine, indépendante des frameworks.

Le langage ubiquitaire

Une idée fondatrice du DDD est le langage ubiquitaire : le code utilise les mêmes mots que le métier.

Dans le service remote-action, une action distante peut être PENDING, COMPLETED ou FAILED, et l'agrégat expose des comportements nommés comme le métier les nomme : complete(), fail(), overrideWith().

Tip

Si un terme existe dans le code mais pas dans le vocabulaire métier, c'est souvent le signe d'une fuite technique dans le domaine.

Les briques tactiques

Le domaine d'un service Feelbat est construit à partir d'un petit nombre de briques.

Brique Rôle Exemple Feelbat
Value Object Représenter une valeur validée et immuable RemoteActionId, ConfigurationName
Entity / Aggregate Porter l'état et les règles métier RemoteAction, Configuration
Repository (port) Contrat de persistance exprimé en termes de domaine RemoteActionRepositoryPort
Service applicatif Orchestrer un cas d'usage RemoteActionService

Value Objects

Un Value Object représente une valeur, pas une identité. Deux value objects sont interchangeables s'ils portent la même valeur.

Chez Feelbat, un value object respecte trois règles :

  • son constructeur est privé ;
  • il est créé par une méthode statique from() qui valide la donnée ;
  • sa valeur est immuable (readonly).

Exemple minimal, l'identifiant d'une action distante (apps/api/remote-action/src/remote-action/domains/values-object/remote-action-id.vo.ts) :

import { assertNotStringEmpty, assertUUID4 } from '@feelbat/shared';

export class RemoteActionId {
  public readonly value: string;

  private constructor(value: string) {
    this.value = value;
  }

  public static from(value: string): RemoteActionId {
    assertNotStringEmpty(value);
    assertUUID4(value);
    return new RemoteActionId(value);
  }
}

Le bénéfice est direct : il devient impossible de créer un RemoteActionId invalide. La validation n'est plus dispersée dans les services, elle est portée par le type lui-même.

Value Object avec règles internes

Un value object peut porter plus qu'une simple chaîne. ConfigurationName (apps/api/configuration/src/domain/value-objects/configuration-name.vo.ts) encapsule une contrainte de longueur :

import { assertStringLengthBetween } from '@feelbat/shared';

const CONFIGURATION_NAME_MIN_LENGTH = 1;
const CONFIGURATION_NAME_MAX_LENGTH = 64;

export class ConfigurationName {
  public readonly value: string;

  private constructor(value: string) {
    this.value = value;
  }

  public static from(value: string): ConfigurationName {
    assertStringLengthBetween(
      value,
      CONFIGURATION_NAME_MIN_LENGTH,
      CONFIGURATION_NAME_MAX_LENGTH,
    );

    return new ConfigurationName(value);
  }
}

Value Object composite

Certaines valeurs sont composées de plusieurs champs cohérents entre eux. ConfigurationTarget (apps/api/configuration/src/domain/value-objects/configuration-target.vo.ts) combine un type de cible et un identifiant optionnel, et valide leur cohérence via des constructeurs nommés :

export class ConfigurationTarget {
  private readonly props: ConfigurationTargetProps;

  private constructor(props: ConfigurationTargetProps) {
    this.props = props;
    this.validate();
  }

  public static global(): ConfigurationTarget {
    return new ConfigurationTarget({ type: ConfigurationTargetType.global() });
  }

  public static sensor(sensorId: string): ConfigurationTarget {
    return new ConfigurationTarget({
      type: ConfigurationTargetType.sensor(),
      targetId: TargetId.from(sensorId),
    });
  }

  private validate(): void {
    if (this.props.type.isGlobal() && this.props.targetId) {
      throw new Error('Global target cannot have targetId');
    }

    if (!this.props.type.isGlobal() && !this.props.targetId) {
      throw new Error('Target id is required');
    }
  }
}

Une cible globale ne peut pas avoir d'identifiant, et une cible non globale doit en avoir un. Cette règle est garantie à la construction : un ConfigurationTarget incohérent ne peut pas exister.

Value Object énuméré

Un value object peut aussi encadrer une énumération. RemoteActionStatus expose des instances prédéfinies et une fabrique validée :

import { assertEnumValid } from '@feelbat/shared';
import { REMOTE_ACTION_STATUTS } from '@feelbat/enum';

export class RemoteActionStatus {
  public static readonly PENDING = new RemoteActionStatus(
    REMOTE_ACTION_STATUTS.PENDING,
  );

  public readonly value: REMOTE_ACTION_STATUTS;

  private constructor(value: REMOTE_ACTION_STATUTS) {
    this.value = value;
  }

  public static from(value: REMOTE_ACTION_STATUTS): RemoteActionStatus {
    assertEnumValid(value, REMOTE_ACTION_STATUTS);
    return new RemoteActionStatus(value);
  }
}

Entities et Aggregates

Une Entity possède une identité stable dans le temps. Deux entités sont différentes même si tous leurs autres champs sont identiques, car elles n'ont pas le même identifiant.

Un Aggregate est un groupe cohérent d'objets traité comme une seule unité. Il définit une frontière de cohérence : toute modification passe par l'agrégat, qui garantit que son état reste valide.

Chez Feelbat, un agrégat suit les mêmes principes qu'un value object, appliqués à un objet vivant :

  • constructeur privé ;
  • création par des factory methods (rehydrate, system, user) ;
  • état interne encapsulé (accessible seulement via des getters) ;
  • comportements exprimés par des méthodes métier, pas par des setters.

Exemple : l'agrégat RemoteAction

L'agrégat RemoteAction (apps/api/remote-action/src/remote-action/domains/aggregates/remote-action.aggregate.ts) modélise le cycle de vie d'une action distante. Son état ne se modifie que par des méthodes métier :

export class RemoteAction {
  private _status: RemoteActionStatus;
  private _payload: RemoteActionPayload;
  private _completed_at?: Date;

  // ... constructeur privé et factory rehydrate()

  public complete(): void {
    this.assertNotFinal();
    this._status = RemoteActionStatus.from(REMOTE_ACTION_STATUTS.COMPLETED);
    this._completed_at = new Date();
  }

  public fail(): void {
    this.assertNotFinal();
    this._status = RemoteActionStatus.from(REMOTE_ACTION_STATUTS.FAILED);
    this._completed_at = new Date();
  }

  public overrideWith(newPayload: RemoteActionPayload): void {
    if (!this.isPending()) {
      throw new Error('Only a PENDING remote action can be overridden');
    }

    this._payload = newPayload;
  }
}

Le code appelant ne fait jamais action.status = 'COMPLETED'. Il exprime une intention métier : action.complete(). L'agrégat reste ainsi le seul responsable de la validité de son état.

Les invariants

Un invariant est une règle qui doit toujours être vraie pour que l'objet soit valide.

RemoteAction protège ses invariants à deux endroits :

  • avant d'agir, avec des gardes comme assertNotFinal() ;
  • à la construction, avec assertStateCoherence() :
private assertStateCoherence(): void {
  if (this.isFinal() && !this._completed_at) {
    throw new Error('Inconsistent state: completed action has no completion date');
  }

  if (this.isPending() && this._completed_at) {
    throw new Error('Inconsistent state: pending action has a completion date');
  }
}

Une action terminée doit avoir une date de complétion ; une action en attente ne peut pas en avoir. Comme cette vérification est appelée dans le constructeur, un RemoteAction incohérent ne peut jamais être instancié, même lorsqu'il est rechargé depuis la base.

Factory methods

Un agrégat n'est pas créé avec new. Il est produit par des méthodes qui portent une intention. Configuration (apps/api/configuration/src/domain/aggregate/configuration.aggregate.ts) distingue par exemple une configuration système d'une configuration utilisateur :

public static system(options: ConfigurationCreateSystemOptions): Configuration {
  const now: Date = new Date();
  return Configuration.rehydrate({
    id: ConfigurationId.generate(),
    name: ConfigurationName.from(options.name),
    scope: ConfigurationScope.system(),
    createdAt: now,
    updatedAt: now,
    type: options.type,
    target: options.target,
    config: options.config,
  });
}

public static user(options: ConfigurationCreateUserOptions): Configuration {
  Configuration.assertUserAuditFields(options);
  // ... construit une configuration avec scope utilisateur et champs d'audit
}

La factory user() impose la présence des champs d'audit (createdBy, updatedBy) via assertUserAuditFields() : une configuration utilisateur sans auteur ne peut pas exister.

Note

Chez Feelbat, rehydrate() est la fabrique bas niveau utilisée notamment pour reconstruire un agrégat depuis la persistance. Les fabriques métier (system(), user()) expriment une intention et s'appuient sur rehydrate().

Repositories

Un repository donne l'illusion d'une collection d'agrégats en mémoire. Il est exprimé dans le langage du domaine, jamais dans celui de la base de données.

Conformément à l'architecture hexagonale, le repository est déclaré comme un port dans la couche application (apps/api/remote-action/src/remote-action/application/ports/remote-action-repository.port.ts) :

export interface RemoteActionRepositoryPort {
  create(options: CreateRemoteActionOptions): Promise<RemoteAction>;
  save(remoteAction: RemoteAction): Promise<RemoteAction>;
  saveMany(remoteActions: RemoteAction[]): Promise<void>;
  findById(id: string): Promise<RemoteAction | null>;
  findMany(filters: RemoteActionFilters): Promise<RemoteAction[]>;
  findPending(targetId: TargetId): Promise<RemoteAction[]>;
  count(filters: RemoteActionFilters): Promise<number>;
}

Le contrat manipule des agrégats (RemoteAction) et des value objects (TargetId), pas des entités ORM. L'implémentation technique (TypeORM, PostgreSQL) vit dans la couche infrastructure et est détaillée dans la page Ports & Adapters.

Services applicatifs

Le service applicatif orchestre un cas d'usage. Il ne contient pas de règle métier : celles-ci appartiennent aux agrégats et aux value objects.

Son travail typique :

  1. convertir les données entrantes (primitives) en value objects ;
  2. charger les agrégats nécessaires via les ports ;
  3. appeler les comportements métier des agrégats ;
  4. persister le résultat via les ports.

Dans RemoteActionService, la décision « peut-on écraser une action existante ? » n'est pas prise par le service : elle est déléguée à l'agrégat.

const existing: RemoteAction = found[0];

// La règle « seule une action PENDING peut être écrasée »
// est portée par l'agrégat, pas par le service.
existing.overrideWith(payload);

return await this.remoteActionRepository.save(existing);

Warning

Si une règle métier apparaît dans un service applicatif (ou pire, dans un resolver), c'est le signe qu'elle devrait vivre dans un agrégat ou un value object.

Et les Domain Events ?

Le DDD décrit souvent des Domain Events émis par les agrégats. Dans l'implémentation actuelle des services Feelbat, les agrégats n'émettent pas d'objets DomainEvent : la communication entre domaines passe par des messages Kafka publiés depuis la couche application, à travers un port de type dispatcher.

await this.dispatcher.dispatchAction({
  type: typeVo,
  actionId: action.id,
  family: target.family,
  deviceId: target.deviceId,
  payload: action.payload,
  pendingsOps: pendings,
});

Ce mécanisme et ses garanties sont documentés dans les pages dédiées à l'événementiel.

Info

Cette page décrit l'implémentation actuelle. Si un pattern de Domain Events explicites est introduit plus tard, cette section devra être mise à jour.

Organisation des dossiers

Les briques du domaine sont regroupées par nature. L'arborescence peut varier légèrement d'un service à l'autre (par exemple domain/ dans configuration et domains/ dans remote-action), mais la logique reste la même : le domaine est isolé, l'application définit les ports, l'infrastructure les implémente.

apps/api/remote-action/src/remote-action/
├── domains/
│   ├── aggregates/        # RemoteAction
│   ├── values-object/     # RemoteActionId, RemoteActionStatus, TargetId...
│   └── mappers/           # transformations internes au domaine
├── application/
│   ├── ports/             # RemoteActionRepositoryPort, dispatcher, authz
│   ├── services/          # RemoteActionService
│   └── dto/               # entrées et sorties des cas d'usage
├── infra/
│   ├── orm/               # implémentations TypeORM des ports
│   ├── messaging/         # dispatcher Kafka
│   └── authz/             # implémentations d'autorisation
└── interfaces/            # resolvers GraphQL, consumers Kafka

Erreurs classiques

  • Exposer des setters publics sur un agrégat au lieu de méthodes métier.
  • Valider une donnée dans le service alors que le value object pourrait le faire.
  • Faire manipuler des entités ORM par le domaine.
  • Mettre une règle métier dans un service applicatif ou un resolver.
  • Créer un agrégat avec new plutôt qu'avec une factory method.

Bonnes pratiques

Conventions Feelbat à respecter :

  • Un value object valide sa donnée dans from() et reste immuable.
  • Un agrégat protège ses invariants et n'expose que des comportements métier.
  • Les repositories sont des ports exprimés en termes de domaine.
  • Les services applicatifs orchestrent, ils ne décident pas des règles métier.
  • Le domaine ne dépend d'aucun framework.

Pour aller plus loin

La règle d'or

Une donnée invalide ou un état incohérent ne doit jamais pouvoir exister.

C'est le rôle des value objects et des agrégats de le garantir, à la construction comme à chaque modification.

À retenir

  • Le DDD s'applique à deux niveaux : stratégique (découpage) et tactique (modélisation).
  • Les value objects portent des valeurs validées et immuables.
  • Les agrégats portent l'état, les comportements et les invariants du domaine.
  • Les repositories sont des contrats de domaine, pas des accès base de données.
  • Les services applicatifs orchestrent sans porter de règle métier.
  • La communication entre domaines passe aujourd'hui par Kafka, pas par des Domain Events explicites.