Services, dépôts et fabriques

VoletTactique Les trois patrons de soutien : où loger la logique qui n’appartient à aucune entité, comment récupérer un agrégat, et comment en fabriquer un.

Service de domaine

Un service de domaine porte une logique qui appartient bel et bien au domaine, mais à aucune entité ni objet valeur en particulier. Deux cas de figure, et deux seulement :

  • l’opération concerne plusieurs agrégats — un transfert entre deux comptes, la vérification d’une règle qui croise une réservation et une séance ;
  • l’opération est une politique dont le domaine parle comme d’une chose en soi : un calcul de tarif, une stratégie d’allocation, un moteur de scoring.

Trois propriétés le caractérisent : il est sans état, il est nommé avec les mots du métier, et il ne dépend d’aucune infrastructure. Un service de domaine qui prend un client HTTP en paramètre n’est pas un service de domaine.

Warning

Le service de domaine est le patron le plus détourné de DDD. Il est tellement commode qu’il devient l’endroit où l’on met tout ce qu’on n’a pas su placer ailleurs — et l’on obtient le modèle anémique : des entités réduites à des accesseurs, et des services de mille lignes qui les manipulent de l’extérieur. La règle de priorité est stricte : d’abord l’objet valeur, ensuite l’entité, le service en dernier recours.

Service de domaine ou service applicatif ?

La confusion la plus fréquente, et elle a des conséquences concrètes puisque les deux ne vivent pas au même endroit.

Service de domaineService applicatif (cas d’usage)
ContientUne règle métierUn enchaînement, aucune règle
Sait ce qu’estUn tarif, une allocationUne transaction, un utilisateur connecté
Dépend deRienDes ports : dépôts, notifications, horloge
EmplacementAu cœur du domaineÀ la frontière — l’application de l’hexagone
Nom typiqueCalculateurDeTarif, AllocateurDePlacesConfirmerReservation, ValiderCommande
TestPur, aucune infrastructureAvec des adaptateurs en mémoire

Le service applicatif est le ValiderCommande de la page hexagonale : il charge un agrégat, appelle une méthode dessus, sauvegarde, publie. S’il contient un if métier, la règle est au mauvais endroit.

​
// ---- Service de DOMAINE : une règle, aucune dépendance ----
export class CalculateurDeTarif {
  calculer(seance: Seance, categorie: CategoriePlace, client: ProfilFidelite): Montant {
    const base = seance.tarifDeBase(categorie);
    const remise = client.remiseApplicable(seance.date());
    return base.moins(base.pourcentage(remise));
  }
}

// ---- Service APPLICATIF : un enchaînement, aucune règle ----
export class ConfirmerReservation {
  constructor(
    private readonly reservations: ReservationRepository,   // Ports
    private readonly notifications: NotificationClient,
  ) {}

  async executer(id: ReservationId): Promise<void> {
    const reservation = await this.reservations.parId(id);
    if (!reservation) throw new ReservationInconnue(id);

    reservation.confirmer();                  // La règle est DANS l'agrégat

    await this.reservations.enregistrer(reservation);
    await this.notifications.confirmer(reservation);
  }
}
# ---- Service de DOMAINE : une règle, aucune dépendance ----
class CalculateurDeTarif:
    def calculer(
        self, seance: Seance, categorie: CategoriePlace, client: ProfilFidelite
    ) -> Montant:
        base = seance.tarif_de_base(categorie)
        remise = client.remise_applicable(seance.date())
        return base - base.pourcentage(remise)


# ---- Service APPLICATIF : un enchaînement, aucune règle ----
class ConfirmerReservation:
    def __init__(
        self,
        reservations: ReservationRepository,        # Ports
        notifications: NotificationClient,
    ) -> None:
        self._reservations = reservations
        self._notifications = notifications

    def executer(self, id: ReservationId) -> None:
        reservation = self._reservations.par_id(id)
        if reservation is None:
            raise ReservationInconnue(id)

        reservation.confirmer()                     # La règle est DANS l'agrégat

        self._reservations.enregistrer(reservation)
        self._notifications.confirmer(reservation)
// ---- Service de DOMAINE : une règle, aucune dépendance ----
pub struct CalculateurDeTarif;

impl CalculateurDeTarif {
    pub fn calculer(
        &self,
        seance: &Seance,
        categorie: CategoriePlace,
        client: &ProfilFidelite,
    ) -> Montant {
        let base = seance.tarif_de_base(categorie);
        let remise = client.remise_applicable(seance.date());
        base - base.pourcentage(remise)
    }
}

// ---- Service APPLICATIF : un enchaînement, aucune règle ----
pub struct ConfirmerReservation<R, N> {
    reservations: R,        // Ports
    notifications: N,
}

impl<R: ReservationRepository, N: NotificationClient> ConfirmerReservation<R, N> {
    pub fn executer(&self, id: ReservationId) -> Result<(), Erreur> {
        let mut reservation = self.reservations
            .par_id(id)?
            .ok_or(Erreur::ReservationInconnue(id))?;

        reservation.confirmer()?;               // La règle est DANS l'agrégat

        self.reservations.enregistrer(&reservation)?;
        self.notifications.confirmer(&reservation)
    }
}

Dépôt

Un dépôt (repository) donne l’illusion d’une collection en mémoire d’agrégats. On y met une racine, on en récupère une, on en retire une. Toute la mécanique de stockage disparaît derrière cette illusion.

Trois règles suffisent à ne pas se tromper.

Un dépôt par racine d’agrégat, et pas un par table ni par entité. Il n’existe pas de LigneReservationRepository : les lignes sont à l’intérieur de l’agrégat, on y accède par la racine.

L’interface appartient au domaine. C’est un port au sens de l’architecture hexagonale : déclaré dans le domaine, implémenté dans l’infrastructure. C’est le principe d’inversion des dépendances appliqué à la persistance.

Il manipule des agrégats entiers, pas des fragments. Pas de méthode qui retourne trois colonnes.

​
// domaine/ports.ts — le port, dans le vocabulaire du domaine
export interface ReservationRepository {
  parId(id: ReservationId): Promise<Reservation | undefined>;
  enregistrer(reservation: Reservation): Promise<void>;
  retirer(id: ReservationId): Promise<void>;

  // Les recherches métier sont acceptables si elles renvoient des agrégats
  provisoiresExpirees(avant: Instant): Promise<Reservation[]>;
}
# domaine/ports.py — le port, dans le vocabulaire du domaine
from typing import Protocol


class ReservationRepository(Protocol):
    def par_id(self, id: ReservationId) -> Reservation | None: ...
    def enregistrer(self, reservation: Reservation) -> None: ...
    def retirer(self, id: ReservationId) -> None: ...

    # Les recherches métier sont acceptables si elles renvoient des agrégats
    def provisoires_expirees(self, avant: Instant) -> list[Reservation]: ...
// domaine/ports.rs — le port, dans le vocabulaire du domaine
pub trait ReservationRepository {
    fn par_id(&self, id: ReservationId) -> Result<Option<Reservation>, Erreur>;
    fn enregistrer(&self, reservation: &Reservation) -> Result<(), Erreur>;
    fn retirer(&self, id: ReservationId) -> Result<(), Erreur>;

    // Les recherches métier sont acceptables si elles renvoient des agrégats
    fn provisoires_expirees(&self, avant: Instant) -> Result<Vec<Reservation>, Erreur>;
}

Ce qu’un dépôt ne doit pas devenir

Un DAO à quarante méthodes. parClientEtDateEtStatutTrieParMontant est le symptôme d’un besoin de lecture déguisé en besoin de domaine. Les écrans, les exports et les tableaux de bord n’ont pas à passer par les agrégats : une requête dédiée, qui lit directement et renvoie exactement ce qu’affiche la page, est plus simple, plus rapide et ne déforme pas le modèle. C’est la séparation lecture/écriture, dont CQRS est la formalisation.

Un endroit où mettre des règles. Un dépôt ne décide rien. S’il contient un if métier, la règle a fui hors du domaine.

Tip

Deux styles de dépôt cohabitent. Le style orienté collection imite un ensemble — l’agrégat modifié est enregistré automatiquement par l’unité de travail de l’ORM, sans appel explicite. Le style orienté persistance exige un enregistrer() explicite. Le second est plus verbeux et bien plus prévisible : on voit dans le code du cas d’usage où la transaction est validée. C’est le seul possible sans ORM à suivi de changements — donc le seul en Rust, et le plus sûr ailleurs.

Fabrique

Une fabrique encapsule une construction assez complexe pour être un sujet en soi. Le besoin est réel dans trois cas : l’assemblage exige des règles qui n’appartiennent pas à l’objet construit, la construction dépend de plusieurs sources, ou la reconstitution depuis la persistance doit contourner les règles de création.

Ce dernier point est le plus concret. Créer une réservation impose de vérifier des règles ; la recharger depuis la base ne doit rien vérifier — elle a déjà existé, et refuser de la charger parce qu’une règle a changé depuis rendrait les anciennes données inatteignables. Deux chemins de construction sont donc nécessaires.

​
export class Reservation {
  // Création : les règles s'appliquent
  static ouvrir(seance: SeanceId, client: ClientId): Reservation {
    return new Reservation(ReservationId.nouveau(), seance, client, []);
  }

  // Reconstitution : aucune règle, l'état a déjà été valide
  static reconstituer(etat: EtatPersiste): Reservation {
    return new Reservation(
      new ReservationId(etat.id),
      new SeanceId(etat.seanceId),
      new ClientId(etat.clientId),
      etat.lignes.map(LigneReservation.reconstituer),
    );
  }
}
class Reservation:
    # Création : les règles s'appliquent
    @classmethod
    def ouvrir(cls, seance: SeanceId, client: ClientId) -> "Reservation":
        return cls(ReservationId.nouveau(), seance, client, [])

    # Reconstitution : aucune règle, l'état a déjà été valide
    @classmethod
    def reconstituer(cls, etat: EtatPersiste) -> "Reservation":
        return cls(
            ReservationId(etat.id),
            SeanceId(etat.seance_id),
            ClientId(etat.client_id),
            [_LigneReservation.reconstituer(l) for l in etat.lignes],
        )
impl Reservation {
    // Création : les règles s'appliquent
    pub fn ouvrir(seance_id: SeanceId, client_id: ClientId) -> Self { /* ... */ }

    // Reconstitution : aucune règle, l'état a déjà été valide.
    // `pub(crate)` la réserve à l'infrastructure du même crate.
    pub(crate) fn reconstituer(etat: EtatPersiste) -> Self {
        Self {
            id: ReservationId(etat.id),
            seance_id: SeanceId(etat.seance_id),
            client_id: ClientId(etat.client_id),
            lignes: etat.lignes.into_iter().map(LigneReservation::reconstituer).collect(),
            etat: etat.etat,
            evenements: Vec::new(),
        }
    }
}
Note

Dans la grande majorité des cas, une méthode de fabrique statique sur l’agrégat suffit, comme ci-dessus, et une classe ReservationFactory séparée serait une cérémonie inutile. La fabrique dédiée se justifie quand la construction dépend de choses que l’agrégat n’a pas à connaître — un service de tarification, un référentiel externe — ou quand elle doit choisir entre plusieurs types concrets, auquel cas on retrouve le Factory Method du GoF.

Récapitulatif

PatronRôleSigne qu’il est mal employé
Service de domaineUne règle qui croise plusieurs agrégatsIl contient toute la logique et les entités sont vides
Service applicatifOrchestrer un cas d’usageIl contient un if métier
DépôtIllusion d’une collection d’agrégatsQuarante méthodes de recherche, ou un if métier
FabriqueConstruction complexe, reconstitutionUne classe dédiée pour un new à deux arguments

Références