Salut à toi !
La dernière fois, on voyait les 4 étapes théoriques sur “le comment” mettre en place le “Port & Adapters Pattern” (épisode précédent ici).
Cette semaine, on rend ces 4 étapes concrètes avec un peu de code.
😱 Tu raté un épisode de la série Hexagonal Architecture ?
Pas de problème. Retrouve la série complète ici.
Hexagonal Architecture : le Comment ? - Épisode 2, un cas simple.
Le use case métier (*), fil rouge qui nous suivra et évoluera au fil des épisodes, est le suivant :
Dans le cadre du développement de notre jeu, un personnage peut lancer un combat contre un adversaire.
• Le combat se déroule au tour par tour grâce à des lancers de dès aléatoires.
• Un tour est composé de deux rounds pour que le personnage et l'adversaire soient à tour de rôle attaquant et défenseur.
• Dès qu'un des combattants descend à 0 points de vie, le combat se termine.
Considérons les caractéristiques suivantes pour les combattants :
• attaque : entier > 0
• défense : entier > 0
• points de vie : entier > 10
Le personnage commence toujours comme attaquant au premier round, l'adversaire comme défenseur.
Un round correspond à un lancé de dé à X faces (X = valeur d'attaque) vs un dé à Y faces (Y = valeur de défense). Lorsque le résultat D est supérieur à 0 (D = l(X) > l(Y)) alors on retire D points de vie au défenseur.
(*) le but n'est pas d'avoir le cas parfait ou le meilleur système de combat mais d'avoir suffisamment de choses à faire pour que ce soit intéressant, tout en restant simple.
Je reprends la trame d’Alistair présentée la dernière fois et on va la remplir petit à petit au fil des épisodes.
Étape 0 : Setup.
Un setup minimaliste :
Des fichiers
.ts
automatiquement reconnus par VSCode.NodeJS 22.12 (lts) avec le flag
--experimental-strip-types
pour exécuter les fichiers.ts
directement avec Node.Le Test Runner fournit par NodeJS.
Le package.json représente bien ce côté minimaliste, à ce stade et dans ce contexte, bien entendu on est loin d’un contexte d’entreprise pour l’instant.
// package.json
{
"name": "hexa-how-to",
"version": "1.0.0",
"description": "Examples to illustrate my articles on my newsletter Marevick's Bazaar (https://maeevick.substack.com)",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node --experimental-strip-types src/setup.ts",
"test": "node --experimental-strip-types --test src/setup.test.ts"
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/Maeevick/hexa-how-to.git"
},
"keywords": [
"Hexagonal",
"Architecture",
"Newsletter",
"How-To"
],
"author": "Maeevick",
"license": "MIT",
"bugs": {
"url": "https://github.com/Maeevick/hexa-how-to/issues"
},
"homepage": "https://github.com/Maeevick/hexa-how-to#readme",
"devDependencies": {
"@types/node": "^22.10.1"
}
}
Le code pour vérifier que le setup fonctionne bien
// setup.ts
type Msg = string;
export const getMsg = (): Msg => "Hello TS in NodeJS!";
console.log(getMsg());
// setup.test.ts
import test from "node:test";
import assert from "node:assert";
import { getMsg } from "./setup.ts";
test("Setup is working", (t) => {
assert.strictEqual(getMsg(), "Hello TS in NodeJS!");
});
Rien de fou, mais l’essentiel est là.
npm run start
> hexa-how-to@1.0.0 start
> node --experimental-strip-types src/setup.ts
(node:34583) ExperimentalWarning: Type Stripping is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
Hello TS in NodeJS!
npm run test
> hexa-how-to@1.0.0 test
> node --experimental-strip-types --test src/setup.test.ts
(node:34620) ExperimentalWarning: Type Stripping is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
(node:34621) ExperimentalWarning: Type Stripping is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
Hello TS in NodeJS!
✔ Setup is working (0.311375ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 69.823417
Étape 1 : Test-to-Test.
Petit résumé des épisodes précédent.
Pour cette étape le Primary Actor est la Test Suite via le Test Runner.
Autrement dit, c’est lui qui fait appel à notre application pour réaliser une tâche business.
Dans notre cas, pas besoin de Primary Adapter notre Test Suite appelle directement le use case de l’application en lui passant les données attendues par le Primary Port.
Le Primary Port : le type/la signature/l’interface de notre use case.
Le Secondary Port : les dépendances acceptées par notre use case.
Le Secondary Actor sera un Test Double “in-memory” parce que je n’ai absolument aucune idée de la solution finale à cet instant. Et c’est un des intérêts du concept : repousser les choix techniques.
Le Secondary Adapter, pas besoin pour le moment non plus, on retourne un Test Double en dur directement (Stub).
Dans le détails, ça donne quoi.
On a les notions métier de Character et Opponent.
On a le use case de “mener un combat entre un character et un opponent” et d’obtenir le vainqueur.
On a les règles métiers à implémenter, de préférence en TDD (*).
(*) Hors scope, on traitera comment avancer sur un algo en TDD dans une autre série, là on y va sans TDD. Les concepts se complètent mais Alistair ne parle pas de TDD dans Hexagonal Architecture, plutôt de tests de non-regression qui s’apparentent plutôt à du ATDD en Test First.
Je crée donc deux fichiers : `makeAFightBetweenCharacterAndOpponent.ts` et `makeAFightBetweenCharacterAndOpponent.test.ts`
Dans mon fichier `makeAFightBetweenCharacterAndOpponent.test.ts`, je commence par écrire mon assertion et je “remonte le flow” au fur et à mesure.
L’assertion du test (le Act ou le Then).
L’appel à mon use case (le Act ou When sur le SUT ou System Under Test).
La définition des data nécessaires à mon use case (les Arrange ou Given).
L’initialisation du use case avec ses dépendances.
La définition des dépendances.
import test from "node:test";
import assert from "node:assert";
import { makeAFightBetweenCharacterAndOpponent } from "./makeAFightBetweenCharacterAndOpponent.ts";
test("When the character is 100% certain to win the opponent, Then the fight declares Chebacca as the winner", async (t) => {
// Regarding the business rules, Chebacca will pick between 1 and 10 on his attack dice
const forRetrievingTheCharacter = async (id: string) => ({
id: "some_uuidv4_1",
name: "Chewbacca the Wookie",
attack: 10,
defense: 10,
health: 10,
});
// Regarding the business rules, Jabba will pick 0 on his defense dice, so he will lose betwwen 1 and 10 points of health and will fall at 0 after a few turns.
const forPickingTheOpponent = async (id: string) => ({
id: "some_uuidv4_0",
name: "Jabba the Hutt",
attack: 0,
defense: 0,
health: 10,
});
// The last dependency that's unpredictible due to 'Math.random', same goes with new Date(), Date.now(), uuidv4(), ...
// Later, it will become the real implementation but for the moment it stays here.
const forLaunchingADice = (numberOfFaces: number) => {
if (numberOfFaces === 0) return 0;
return Math.floor(Math.random() * numberOfFaces) + 1;
};
// Here, we initialize the dependencies and inject them in our application respecting the Secondary Port's contract
const makeAFightInitialized = makeAFightBetweenCharacterAndOpponent(
forRetrievingTheCharacter,
forPickingTheOpponent,
forLaunchingADice
);
// Here, we initialize some dummies data following the Primary Port's contract
const characterId = "some_uuidv4_0";
const opponentId = "some_uuidv4_1";
// Here, we use our application with the expected data
const result = await makeAFightInitialized(characterId, opponentId);
assert.strictEqual(result, "Chewbacca the Wookie wins");
});
En parallèle, à chaque ligne grosso-modo, je règle les problèmes de typages et de compilation en définissant mes types dans le fichier `makeAFightBetweenCharacterAndOpponent.ts`.
Port & Adapters Pattern fonctionne avec des langages faiblement typés et dynamiquement typés (comme JS) donc il n’y a pas de problème à ce servir de TypeScript dans un sens ou dans l’autre.
Selon tes préférences :
tu crées tes types puis le simplémentation
tu crées ce dont tu as besoin (ici dans le fichier de test) puis tu explicites les types (et tu doubles check au passage).
Ayant fait 20ans de JS, j’ai plus souvent le réflexe de la deuxième technique (moins depuis que j’ai touché à Haskell ou Rust). Bref.
// Domain data structures
// No problem with duplication because I don't know if Character and Opponent data structures will stays the same.
export type Character = {
id: string;
name: string;
attack: number;
defense: number;
health: number;
};
export type Opponent = {
id: string;
name: string;
attack: number;
defense: number;
health: number;
};
// Secondary Ports
// Same here. No problem with duplication because I don't know if Character and Opponent are the same concept or not.
export type ForRetrievingTheCharacter = (id: string) => Promise<Character>;
export type ForPickingTheOpponent = (id: string) => Promise<Opponent>;
export type ForLaunchingADice = (numberOfFaces: number) => number;
// Primary Ports
// It's a personal choice but I like to prefix the application's use case with an "I".
// Not a reference to "Interface" but to the first person of singular "I" : "As a user, I make a fight between character and opponent".
export type IMakeAFightBetweenCharacterAndOpponent = (
forRetrievingTheCharacter: ForRetrievingTheCharacter,
forPickingTheOpponent: ForPickingTheOpponent,
forLaunchingADice: ForLaunchingADice
) => (characterId: string, opponentId: string) => Promise<string>;
// Application's use case to handle buisiness logic
export const makeAFightBetweenCharacterAndOpponent: IMakeAFightBetweenCharacterAndOpponent =
(forRetrievingTheCharacter, forPickingTheOpponent, forLaunchingADice) =>
async (characterId, opponentId) => { return "" }
Étape 2 : Real-to-Test.
Étape 3 : Test-to-Real.
Étape 4 : Real-to-Real.
...coming soon...
On continuera avec les étapes 2,3,4 et switcher les Primary/Secondary Actors et Adapters lors d’un prochain épisode.
J’en vois certains au fond de la salle qui doivent se concentrer pour s’y retrouver dans mon gloubi-boulga (notamment avec les currying pour l’injection de dépendance).
Mais aussi, parce qu’à ce stade, le code pourrait aussi bien tourner en front qu’en back, web ou cli, standalone ou multiplayer...
⚠️ NOTES
Le repo devrait s'enrichir dans les jours/semaines qui viennent. Le lien ici : https://github.com/Maeevick/hexa-how-to
Tous les feedbacks sont les bienvenus 🙏
Mise en pratiques futures.
On m'a déjà demandé plusieurs fois comment mettre en place le pattern en front donc sûrement que je ferai un épisode dédié.
N'hésite pas à m'envoyer tes suggestions ici ou sur LinkedIn.
Bon dimanche à toi !
Je remets les deux talks qui peuvent t'aider sur le sujet, si tu ne les as pas encore vu c'est l'occasion :
Alistair Cockburn sur Hexagonal Architecture : https://www.youtube.com/live/k0ykTxw7s0Y
Kent Beck sur TDD : https://www.youtube.com/live/C5IH0ABmyc0
Tchuss ❤️👺
Aurel