Modéliser des agrégats grâce au pattern "Decider"

Publié dans développement
le

Modéliser les transitions d'un agrégat peut s'avérer complexe, d'autant plus dans un langage fonctionnel dans lequel les structures de données représentent le cœur du langage. Heureusement je découvre cette semaine le pattern Decider !

Ce n'est un secret pour aucun d'entre vous, j'aime l'approche orientée métier dans le développement logiciel, ça a totalement changé ma manière de développer. Alors quand je découvre un pattern qui va dans ce sens, je suis bien obligé d'en parler !

Il était une fois...

J'aime beaucoup le langage Typescript. Quand j'ai un POC ou un Kata à réaliser, je me tourne souvent vers ce langage pour son côté très dynamique et son système de types poussé (en attendant que je sois prêt à passer sur un langage totalement fonctionnel 😁).

Seulement quand je l'utilise pour quelque chose de plus sérieux, notamment pour une application spécifique dans laquelle j'ai envie de conserver cette approche orientée métier, j'ai beaucoup plus de mal.

Pourquoi donc me direz-vous ? Et bien tout simplement car les classes sont bien souvent délaissées au profit de structures de données plus simple (à base de {}), en bien ou en mal, là n'est pas la question et qu'en se faisant, on perd le contrôle sur la cohérence de l'objet.

Toujours est-il que partir dans le sens contraire peut vous poser bien des soucis, notamment lors de l'intégration avec d'autres librairies pour la persistance où ce genre de choses, vous amenant à tordre votre classe ou créer des représentations intermédiaires.

Admettons que je décide de réaliser une application de gestion de tâches, on va faire très très simple ici :

type Task = {
  title: string;
  done: boolean;
};

Dans la plupart des applications qu'on peut trouver en Typescript (sur Github principalement), peu de personnes se soucient des mutations de ce type Task et il ne sera pas rare de voir un task.done = true; directement dans un handler HTTP. Alors ça fonctionne, mais imaginez la même chose sur une application plus complexe et sur la maintenabilité à plus ou moins long terme (sans parler du peu d'expressivité de cette modélisation)…

Le pattern "Decider"

Ce pattern, découvert via la conférence de Jérémie Chassaing, va vous permettre de conserver cette approche centrée sur les données mais en y ajoutant l'expressivité d'un modèle orienté métier.

Définition

Un Decider possède 4 propriétés :

  • une fonction decide : à partir d'une commande et d'un état détermine les événements produits,
  • une fonction evolve : à partir d'un état et d'une liste d'événements (générés précédemment), génère un nouvel état,
  • un initialState qui permet de savoir d'où on part,
  • une fonction isTerminal : à partir d'un état, détermine si l'état final est atteint, et que par conséquent, l'objet ne sera plus jamais modifié (permet par exemple de disposer l'objet).

Transposé en Typescript, on aura quelque chose dans ce genre là :

type Decider<Command, Event, State> = {
  decide: (cmd: Command, state: State) => Event[];
  evolve: (state: State, event: Event) => State;
  initialState: State;
  isTerminal: (state: State) => boolean;
};

Si on reprend notre exemple de gestion de tâches, on va alors pouvoir réfléchir en commandes, états et événements :

// Ici j'utilise un namespace mais on pourrait définir un fichier task.ts et exporter les membres.
namespace Task {
  // L'état en readonly, impossible de le modifier sans passer par le decider
  export type State = Readonly<{
    title: string;
    done: boolean;
  }>;

  // Les différentes commandes possible sur notre tâche, la propriété `type` sert de discriminant
  export type Command =
    | {
        type: "Create";
        title: string;
      }
    | {
        type: "ChangeTitle";
        title: string;
      }
    | {
        type: "MarkAsDone";
      };

  // Les événements, au passé, la propriété `type` en discriminant aussi
  export type Event =
    | {
        type: "Created";
        title: string;
      }
    | {
        type: "TitleChanged";
        title: string;
      }
    | {
        type: "MarkedAsDone";
      };

  // Et enfin notre decider, qui prend les décisions métiers et lèvent les événements appropriés et s'occupe de modifier l'état
  export const decider: Decider<Command, Event, State> = {
    decide: (c, s) =>
      match(c, {
        Create: (c) => {
          if (s.title) {
            throw new Error("Task already created");
          }
          return [{ type: "Created", title: c.title }];
        },
        ChangeTitle: (c) => {
          if (s.done) {
            throw new Error("Task is done");
          }
          return [{ type: "TitleChanged", title: c.title }];
        },
        MarkAsDone: (_) => (s.done ? [] : [{ type: "MarkedAsDone" }]),
      }),
    evolve: (s, e) =>
      match(e, {
        Created: (e) => ({ ...s, title: e.title }),
        TitleChanged: (e) => ({ ...s, title: e.title }),
        MarkedAsDone: (_) => ({ ...s, done: true }),
      }),
    initialState: { title: "", done: false },
    isTerminal: (s) => s.done,
  };
}

// Petits utilitaires pour éviter les switch un peu cracra et assurer l'exhaustivité des cas

type ExtractCallbacks<Input extends { type: string }, Output> = {
  [K in Input["type"]]: (_: Extract<Input, { type: K }>) => Output;
};

function match<Input extends { type: string }, Output>(
  cmd: Input,
  cases: ExtractCallbacks<Input, Output>
): Output {
  return cases[cmd.type as keyof typeof cases](
    cmd as Extract<Input, { type: Input["type"] }>
  );
}

Évidemment lors de l'utilisation de ce type de pattern, les effets de bord (persistance, appels externes, …) sont repoussés dans les couches plus hautes, à la manière de mon article précédent et de l'approche Functional Core, Imperative Shell.

Et qui dit fonctions pures, dit testabilité ! En effet, tester le Decider est un vrai jeu d'enfant !

Utilisation

Une fois que vous avez votre Decider, votre code métier donc, écrit, vous n'y touchez plus ! Il ne nous manque q'un élément permettant d'orchestrer le tout et de choisir quoi faire de l'état et des événements.

Par exemple, si on souhaite uniquement manipuler notre decider en mémoire, on peut partir d'une fonction de ce genre :

function inMemory<Command, Event, State>(
  decider: Decider<Command, Event, State>
): (_: Command) => State {
  let state = decider.initialState;

  return (cmd: Command) => {
    const events = decider.decide(cmd, state);
    state = events.reduce(decider.evolve, state);
    return state;
  };
}

Cette fonction pourra s'appliquer sur n'importe quel type de decider :

const taskDeciderInMemory = inMemory(Task.decider);

// Ici tout est typé ! Impossible de passer un `type` qui ne fait pas parti de l'union `Task.Command`
console.log(taskDeciderInMemory({ type: "Create", title: "Acheter du lait" }));
console.log(
  taskDeciderInMemory({
    type: "ChangeTitle",
    title: "Acheter du lait, et des œufs !",
  })
);
console.log(taskDeciderInMemory({ type: "MarkAsDone" }));

// { title: "Acheter du lait", done: false }
// { title: "Acheter du lait, et des œufs !", done: false }
// { title: "Acheter du lait, et des œufs !", done: true }

Envie de persister l'état ailleurs ? De lever les événements dans des Process (cf. la conférence) ? Il suffit d'écrire une alternative à la fonction inMemory ! C'est aussi simple que ça.

Cette approche est d'ailleurs extrêmement pratique si vous pratiquez l'Event Sourcing, il suffit en effet de persister les événements plutôt que l'état et de rappeler la fonction evolve avec le flux d'événements pour réhydrater votre agrégat !

Conclusion

J'ai laissé de côté certains aspects abordés dans la conférence et l'article associé, notamment la composition de plusieurs deciders pour la coordination mais les possibilités sont folles !