header

Depuis l’arrivée des frameworks frontend et l’avènement des applications de type SPA (Single Page Application), la volatilité de l’écosystème frontend et l’adhérence avec les framework font qu’il est aujourd’hui difficile de produire des applications robustes et durables.

L’objectif de cet article est de montrer comment la mise en place de l’architecture hexagonale côté frontend peut répondre à cette problématique.

Présentation

Matthieu

Architecte et développeur backend, je développe principalement en Java, JS, Go. En ce moment, je commence à apprivoiser le Rust.

J’adore tout ce qui touche à la CI/CD (Gitlab 🦊 ❤️), imaginer et créer des applications robustes, performantes et qui consomment peu de ressources.

Je partage ma veille et mes découvertes au travers d’articles de blog.

Quand je ne suis pas dans mon bureau en train de bricoler mon imprimante 3D, on peut me retrouver sur l’eau 🛶 !

    

Sebastián

Architecte et développeur backend.

Ancien développeur C#, jusqu’à me faire séduire par l’open source et NodeJS. J’adore Rust (mais je ne sais pas encore si c’est réciproque 😅).

Linux ❤️ est mon copilote et rien ne me manquera. Comme Matthieu, je suis un passionné de la CI/CD (Gitlab 🦊 gang), j’aime bien optimiser/automatiser tout ce qui est optimisable/automatisable.

Je fais plus de veille que ce que je devrais, ou c’est juste que la journée n’est pas assez longue.

Si vous parlez “un poquito de español”, n’hésitez pas à me parler dans la langue de Cervantès 🙂

  

Remerciements

Merci à Simon Duhem pour son aide sur la partie style et les web components.

Merci à Julien Topçu qui nous a conseillé sur la partie architecture hexagonale et qui a répondu à nos questions tout au long du projet.

Constat

Aujourd’hui la majorité des applications frontend sont produites à l’aide de frameworks.

Les trois principaux frameworks du marché sont React, VueJS et Angular.

Les frameworks UI permettent de développer plus rapidement les applications. Ils gèrent nativement la réactivité ainsi que la compatibilité des applications avec les différents navigateurs.

Problématiques

Tout le code dédié à la partie métier frontend va généralement se retrouver également lié au fonctionnement du framework.

Lorsqu’un framework devient déprécié, il faut de nouveau réécrire toute l’application dans un nouveau framework.

Zoom sur le cas AngularJS

2009 : Google sort la première version de AngularJS.

Le framework va devenir très populaire et beaucoup de développeurs vont réaliser leurs applications SPA à l’aide de ce framework.

2018 : L’équipe annonce la fin du développement du framework et une fin de maintenance au 31 décembre 2021 (https://blog.angular.io/stable-angularjs-and-long-term-support-7e077635ee9c).

Suite à cette annonce les équipes chargées de maintenir des apps angularjs se retrouvèrent confrontées à un choix pour maintenir leurs apps :

  • Migrer de AngularJS vers Angular ?
  • Réécrire l’application dans un autre framework ?
  • Garder son code legacy et croiser les doigts pour que personne ne trouve de faille ?

La solution retenue passera certainement par une réécriture plus ou moins importante du code, y compris le code métier front de l’application.

Comment l’éviter ?

Pour éviter de tomber dans ce genre de piège, il faut chercher à décorréler la partie métier frontend de la partie UI / Framework.

L’idée est simple : construire une application où d’un côté le framework se charge uniquement du rendu html et de la réactivité des composants et d’un autre le métier du front est isolé dans du code agnostique.

Avantages :

  • Le code lié au framework est isolé
  • Notre code métier devient durable car agnostique

Sur le papier l’idée semble simple, mais comment mettre en place ce découpage ?

L’architecture hexagonale au service du frontend

L’architecture hexagonale est un pattern d’architecture créé par Alistair Cockburn qui place la couche métier au centre de l’application (hexagone) tout en garantissant un couplage lâche avec les briques techniques.

Concepts de base :

  • Le domaine métier est agnostique et n’a pas de dépendances.
  • L’étanchéité du domaine métier est garantie via le système de ports.
  • Les couches (adapters) gravitants autour de l’hexagone doivent respecter les interfaces définies dans la couche port pour communiquer avec le domaine.

Pour aller plus loin sur l’architecture hexagonale, voir les ressources dans la partie liens.

Voici un exemple de découpage d’application frontend utilisant l’architecture hexagonale :

schema

Projet de départ

Les différentes étapes à suivre se baseront sur la migration d’une app legacy AngularJS vers un modèle hexagonal.

https://gitlab.com/thekitchen/frontend-legacy-app

Cette application est un Twitter simplifié qui contient les features suivantes :

  • authentification
  • création de compte
  • création de tweets
  • affichage des tweets
  • like

Elle a été réalisée en AngularJS pour se mettre dans les conditions d’une application réalisée à l’aide d’un framework déprécié.

Objectif

L’objectif de l’article est de montrer étape par étape comment partir d’un projet legacy pour arriver à un découpage en architecture hexagonale nous permettant de changer facilement le framework de notre application.

Ce ne sera pas une recette magique applicable sur toutes les applications mais plutôt les étapes du processus de migration que nous avons réalisées durant nos recherches.

Organisation du nouveau projet

Un projet de ce type a besoin de quelques pré-requis techniques pour permettre un développement serein et fluide, en plus d’apporter une isolation stricte des briques techniques associées au domaine.

Pour se concentrer purement dans le code, et pas dans l’outillage, nous considérons que l’utilisation d’un monorepo est un besoin.

Évidement, le choix d’un outil de gestion de packages est secondaire. De notre côté nous avons choisi pnpm + turborepo (si le besoin se présente).

L’organisation hiérarchique du monorepo sera la suivante :

.
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── apps
│   ├── angular-app
│   ├── angularjs-app
│   ├── backend-api
│   ├── react-app
│   └── vue-app
├── configs
│   ├── eslint-config-hexademo
│   └── tsconfig
├── e2e
│   ├── package.json
│   └── tests
├── packages
│   ├── adapters
│   ├── domain
│   ├── loader
│   ├── style
│   └── web-components

Les détails concernant chacun des dossiers seront expliqués au fur et à mesure au cours de l’article.

Identifier son métier

Le métier de notre application est composé de deux parties :

  • Gestion des comptes
  • Gestion des tweets

Pour la partie compte, l’application permet de :

  • créer un compte
  • s’authentifier
  • se déconnecter
  • savoir si l’utilisateur est authentifié
  • récupérer le jwt de l’utilisateur authentifié
  • récupérer le nom de l’utilisateur authentifié

Pour la partie tweets, l’application permet de :

  • création des tweets
  • récupérer les tweets
  • liker des tweets

Domaine - Création des interfaces API

Une fois notre métier identifié, nous pouvons maintenant rédiger les interfaces de notre domaine.

La couche API contient toutes les interfaces permettant de communiquer avec le domaine métier.

Cette couche est fournie par le domaine pour garantir son étanchéité.

schema

API Compte

Voici l’interface API créée à partir du métier des comptes décrit précédemment

ports/api/account.ts

interface IAccountAPI {
	authenticate(username: string, password: string): Promise<string>;
	isAuthenticated(): boolean;
	logout(): Promise<void>;
	register(username: string, password: string): Promise<Registration>;
	getToken(): string;
	getUsername(): string;
}

export type { IAccountAPI };

API Tweet

Et voici l’interface API créée à partir du métier de la partie tweet

ports/api/twitter.ts

interface ITwitterAPI {
	tweet(message: string): Promise<Tweet>;
	like(tweetId: string): Promise<Tweet>;
	listTweets(): Promise<Array<Tweet>>;
}

export type { ITwitterAPI };

Domaine - Création des interfaces SPI

La couche SPI (Service Provider Interface) contient toutes les interfaces requises et fournies par le domaine pour interagir avec la donnée.

C’est ici que nous allons définir les interfaces permettant au domaine de récupérer / créer des tweets, s’authentifier etc…

Ces interfaces seront ensuite implémentées par la couche adapter.

schema

ports/spi/iauthentication-adapter.ts

interface IAuthenticationAdapter {
	auth(username: string, password: string): Promise<string>;
	register(username: string, password: string);
}

ports/spi/itweet-adapter.ts

interface ITweetAdapter {
	listTweets(): Promise<Array<Tweet>>;
	createTweet(tweet: Tweet): Promise<Tweet>;
	likeTweet(tweetId: string): Promise<Tweet>;
}

Domaine - Écriture du code métier

Maintenant que nos interfaces API et SPI sont codées, nous pouvons passer à l’écriture du code métier.

Partie métier des comptes

Pour la partie compte, nous avons les règles métier suivantes à appliquer :

Il n’est pas possible de créer un compte sans utilisateur / mot de passe

Il n’est pas possible de s’authentifier avec un mot de passe vide

Le jeton doit être persisté lors de l’authentification

account.ts
import { IAccountAPI } from "./ports/api";
import { Registration } from "./types/registration";
import { IAuthenticationAdapter } from "./ports/spi/iauthentication-adapter";
import { ISessionAdapter } from "./ports/spi/isession-adapter";

class Account implements IAccountAPI {
	private authAdapter: IAuthenticationAdapter;

	private sessionAdapter: ISessionAdapter;

	private defaultSessionDuration: number;

	constructor(
		authAdapter: IAuthenticationAdapter,
		sessionAdapter: ISessionAdapter
	) {
		this.authAdapter = authAdapter;
		this.sessionAdapter = sessionAdapter;
		this.defaultSessionDuration = 120;
	}

	async authenticate(username: string, password: string): Promise<string> {
		this.checkThatUserIsFilled(username);
		this.checkThatPasswordIsFilled(password);

		try {
			const token = await this.authAdapter.auth(username, password);

			this.sessionAdapter.storeValue(
				"auth-token",
				token,
				this.defaultSessionDuration
			);

			return token;
		} catch (error) {
			throw new Error(
				"Something went wrong during the authentication. Check your username and password."
			);
		}
	}

	async register(username: string, password: string): Promise<Registration> {
		this.checkThatUserIsFilled(username);
		this.checkThatPasswordIsFilled(password);

		try {
			await this.authAdapter.register(username, password);
			return {
				username,
				status: "CREATED",
			};
		} catch (error) {
			return {
				username,
				status: "ERROR",
			};
		}
	}

	async logout(): Promise<void> {
		this.sessionAdapter.flush();
	}

	getToken(): string {
		const token = this.sessionAdapter.getValue("auth-token");
		if (!token) {
			throw new Error("Token not found");
		}

		return token;
	}

	getUsername(): string {
		const token = this.getToken();
		const [user] = atob(token).split(":");
		if (!user) {
			throw new Error("Invalid token format");
		}
		return user;
	}

	isAuthenticated(): boolean {
		try {
			const token = this.getToken();
			if (token.length) {
				return true;
			}
			return false;
		} catch (error) {
			return false;
		}
	}

	checkThatUserIsFilled(username: string) {
		if (!username.length) {
			throw new Error("Username could not be empty");
		}
	}

	checkThatPasswordIsFilled(password: string) {
		if (!password.length) {
			throw new Error("Password could not be empty");
		}
	}
}

export { Account };

Partie métier des tweets

Pour les tweets, nous avons les règles métier suivantes à appliquer lors de la création d’un tweet :

Il n’est pas possible de créer un tweet vide

Il n’est pas possible de créer un tweet sans auteur

Un tweet ne doit pas faire plus de 144 caractères

Pour commencer, nous allons créer un type Tweet avec les attributs nécessaires à notre domaine métier des tweets côté front.

⚠️ Ce type ne doit pas obligatoirement correspondre au format retourné par notre backend. Il est la représentation de l’entité métier de notre métier frontend.

types/tweet.ts
type Tweet = {
	id?: string;
	author: string;
	message: string;
	likes?: number;
	createdAt?: string;
};

export type { Tweet };

Nous pouvons ensuite passer à nos règles métier :

twitter.ts
import { Tweet } from "./types/tweet";
import { ITweetAdapter } from "./ports/spi/itweet-adapter";
import { IAccountAPI, ITwitterAPI } from "./ports/api";
import { ITweetDispatcher } from "./ports/spi/itweet-dispatcher";

class Twitter implements ITwitterAPI {
	accountAPI: IAccountAPI;

	tweetAdapter: ITweetAdapter;

	tweetDispatcher: ITweetDispatcher;

	constructor(
		accountAPI: IAccountAPI,
		tweetAdapter: ITweetAdapter,
		tweetDispatcher: ITweetDispatcher
	) {
		this.accountAPI = accountAPI;
		this.tweetAdapter = tweetAdapter;
		this.tweetDispatcher = tweetDispatcher;
	}

	async listTweets(): Promise<Tweet[]> {
		const tweets = await this.tweetAdapter.listTweets();
		return tweets.reverse();
	}

	async tweet(message: string): Promise<Tweet> {
		this.#checkThatMessageIsFilled(message);
		this.#checkTweetLength(message);

		const author = this.accountAPI.getUsername();
		this.#checkThatAutorIsFilled(author);

		const tweet = await this.tweetAdapter.createTweet({ message, author });
		this.tweetDispatcher.emitTweetCreated(tweet);
		return tweet;
	}

	like(tweetId: string): Promise<Tweet> {
		return this.tweetAdapter.likeTweet(tweetId);
	}

	#checkThatMessageIsFilled(message: string) {
		if (!message.length) {
			throw new Error("Message could not be empty");
		}
	}

	#checkThatAutorIsFilled(author: string) {
		if (!author.length) {
			throw new Error("Author could not be empty");
		}
	}

	#checkTweetLength(message: string) {
		if (message.length > 144) {
			throw new Error("Message length must be lower than 144 characters");
		}
	}
}

export { Twitter };

Domaine - Mise en place des stubs

Pour tester le code métier de notre domaine sans avoir besoin de déployer le backend associé, nous allons mettre en place des stubs d’adapters que nous allons injecter à la place des adapters réels.

Un stub est une technique employée pour isoler une portion de code afin de la rendre autonome. Dans notre cas les stubs seront des implémentations en mémoire qui retourneront de la donnée factice.

Quelques points importants à savoir dans notre cas :

  • les stubs doivent implémenter les interfaces SPI et donc respecter les signatures de méthodes
  • toujours pour garantir l’étanchéité du domaine, les stubs sont créés dans le domaine
  • un bon stub doit retourner les jeux de données nécessaires pour tester tous les cas métiers

Dans notre projet, nous avons placé les stubs dans un répertoire stubs à côté des interfaces SPI.

https://gitlab.com/thekitchen/frontend-hexagonal-demo/-/tree/main/packages/domain/src/ports/spi/stubs

ports/spi/stubs/authentication-inmem-adapter.ts
import { IAuthenticationAdapter } from "../iauthentication-adapter";

class AuthenticationInMemAdapter implements IAuthenticationAdapter {
	users;

	constructor() {
		this.users = [
			{
				username: "unicorn",
				password: "rainbow",
			},
		];
	}

	async auth(username: string, password: string): Promise<string> {
		const found = this.users.find((user) => user.username === username);
		if (!found || found.password !== password) {
			throw new Error("Bad credentials");
		}
		return btoa(`${username}:${password}`);
	}

	async register(username: string, password: string) {
		const found = this.users.find((user) => user.username === username);
		if (found) {
			throw new Error("User already exists");
		}
		this.users.push({
			username,
			password,
		});
	}
}

export { AuthenticationInMemAdapter };

ports/spi/stubs/tweet-inmem-adapter.ts
import { nanoid } from "nanoid";
import { Tweet } from "../../../types/tweet";
import { ITweetAdapter } from "../itweet-adapter";

class TweetInMemAdapter implements ITweetAdapter {
	tweets: Tweet[];

	constructor() {
		this.tweets = [];
	}

	async listTweets(): Promise<Tweet[]> {
		return this.tweets;
	}

	async createTweet(tweet: Tweet): Promise<Tweet> {
		const tweetToCreate: Tweet = {
			id: nanoid(10),
			createdAt: new Intl.DateTimeFormat("fr-FR", {
				weekday: "short",
				year: "numeric",
				month: "short",
				day: "numeric",
				hour: "2-digit",
				minute: "2-digit",
				second: "2-digit",
			}).format(new Date()),
			likes: 0,
			...tweet,
		};
		this.tweets.push(tweetToCreate);
		return tweetToCreate;
	}

	async likeTweet(tweetId: string): Promise<Tweet> {
		const tweet = this.tweets.find((t) => t.id === tweetId);
		if (!tweet) throw new Error(`Tweet ${tweetId} not found`);

		if (!tweet.likes) {
			tweet.likes = 0;
		}
		tweet.likes += 1;

		return tweet;
	}
}

export { TweetInMemAdapter };

Tester son domaine métier

Maintenant que nous avons des stubs, nous pouvons facilement tester notre domaine métier.

A la différence des tests front habituels, nous allons grâce au découpage en hexagone pouvoir tester les règles métier de notre domaine plutôt que de monter des composants UI pour tester leurs comportements.

⚠️ : nous n’entendons pas par là que les tests de composants sont inutiles mais plutôt que ce découpage nous permet de réaliser de nouveaux types de tests sur notre application front. Étant donné que notre métier est décorrélé du framework, nous pouvons facilement tester notre règles métier directement.

Exemple de tests métiers :

Instanciation de la classe Twitter avec des adapters stubs :

const twitter = new Twitter(new TweetInMemAdapter());

Test de la règle sur le nombre de caractères :

test("should throw error if new tweet message is longer than 144 chars", async () => {
	await expect(() => twitter.tweet(new Array(160).join("x"))).rejects.toThrow(
		"Message length must be lower than 144 characters"
	);
});

Test du like d’un tweet :

test("should like a tweet", async () => {
	const tweet = await twitter.tweet("Hi !");
	expect(tweet).toHaveProperty("id");
	expect(tweet).toHaveProperty("likes", 0);

	const updated = await twitter.like(tweet.id as string);
	expect(updated).toHaveProperty("likes", 1);
});

Vous pouvez retrouver tous les tests de l’application à côté des fichiers métiers *.spec.ts : https://gitlab.com/thekitchen/frontend-hexagonal-demo/-/tree/main/packages/domain/src

Écriture des adapters

Maintenant que notre métier est écrit et testé, nous pouvons passer à la réalisation de la couche adapter.

La couche adapter de notre hexagone implémente les interfaces de type SPI.

Cette couche va avoir pour responsabilité d’interagir avec la donnée, généralement via des appels à des API (REST, GraphQL, etc…) pour des applications frontend.

Dans notre cas, la couche adapter sera responsable des appels à notre backend qui expose une API REST.

Comme pour la partie métier, nous avons découpé en deux adapters. Un responsable d’appeler l’API des comptes, l’autre d’appeler l’API des tweets.

Comme vu plus haut dans la partie SPI, ces adapters doivent implémenter les interfaces définies dans la couche SPI du domaine.

Voici nos adapters utilisés pour communiquer avec notre API REST :

authentication-rest-adapter.ts
import { IAuthenticationAdapter } from "@hexademo/domain";

class AuthenticationRestAdapter implements IAuthenticationAdapter {
	async auth(username: string, password: string): Promise<string> {
		const response = await fetch("http://localhost:8080/signin", {
			method: "POST",
			headers: {
				Accept: "text/plain",
				"Content-Type": "application/json",
			},
			body: JSON.stringify({
				username,
				password,
			}),
		});
		const token = await response.text();
		return token;
	}

	async register(username: string, password: string) {
		const response = await fetch("http://localhost:8080/signup", {
			method: "POST",
			headers: {
				Accept: "application/json",
				"Content-Type": "application/json",
			},
			body: JSON.stringify({
				username,
				password,
			}),
		});
		if (response.status !== 201) {
			throw new Error("Registration error");
		}
	}
}

export { AuthenticationRestAdapter };

tweet-rest-adapter.ts
import type { Tweet, ITweetAdapter } from "@hexademo/domain";

/**
 * Generate output date
 *
 * @param {Date} date input date
 * @returns {string} output
 */
function formatDate(date: Date): string {
	return new Intl.DateTimeFormat("fr-FR", {
		weekday: "short",
		year: "numeric",
		month: "short",
		day: "numeric",
		hour: "2-digit",
		minute: "2-digit",
		second: "2-digit",
	}).format(date);
}
class TweetRestAdapter implements ITweetAdapter {
	async listTweets(): Promise<Tweet[]> {
		const response = await fetch("http://localhost:8080/tweets");
		const jsonResp = await response.json();
		const tweets: Array<Tweet> = [];
		for (const tweet of jsonResp) {
			tweets.push({
				id: tweet.id,
				message: tweet.message,
				author: tweet.author,
				createdAt: formatDate(new Date(tweet.created_at)),
				likes: tweet.likes,
			});
		}
		return tweets;
	}

	async createTweet(tweet: Tweet): Promise<Tweet> {
		const response = await fetch("http://localhost:8080/tweets", {
			method: "POST",
			headers: {
				Accept: "application/json",
				"Content-Type": "application/json",
			},
			body: JSON.stringify({
				message: tweet.message,
				author: tweet.author,
			}),
		});

		const jsonResp = await response.json();

		return {
			id: jsonResp.id,
			message: jsonResp.message,
			author: jsonResp.author,
			createdAt: formatDate(new Date(jsonResp.created_at)),
			likes: jsonResp.likes,
		};
	}

	async likeTweet(tweetId: string): Promise<Tweet> {
		const response = await fetch(
			`http://localhost:8080/tweets/${tweetId}/like-tweet`,
			{
				method: "POST",
				headers: {
					Accept: "application/json",
					"Content-Type": "application/json",
				},
			}
		);
		const jsonResp = await response.json();
		return {
			id: jsonResp.id,
			message: jsonResp.message,
			author: jsonResp.author,
			createdAt: formatDate(new Date(jsonResp.created_at)),
			likes: jsonResp.likes,
		};
	}
}

export { TweetRestAdapter };

L’avantage de cette technique est qu’elle nous garantit l’évolutivité de la couche de communication avec notre backend.

Si à l’avenir nous souhaitons utiliser une API GraphQL ou même une autre API externe pour récupérer la donnée. Nous aurons juste à créer un nouvel adapter adapté au nouveau besoin.

La clé étant que c’est le domaine qui détermine le contrat d’interface (entrées et sorties) et que la couche adapter respecte ce contrat.

Chargement de l’hexagone

Pour instancier l’hexagone, nous avons besoin de brancher les adapters aux ports spi du domain.

D’un point de vue technique, il faut injecter les dépendances (adapters) via les constructeurs existants dans notre couche domain.

Pour faciliter cette orchestration, nous avons choisi de créer un package utilitaire appelé @hexademo/loader. Ce package se charge de l’instanciation de classes dans le bon ordre.

Voici le code de notre loader :

packages/loader/index.ts
import {
	AuthenticationInMemAdapter,
	SessionCookieAdapter,
	TweetIndexedDbAdapter,
	TweetEventsDispatcher,
} from "@hexademo/adapters";
import { Account, IAccountAPI, Twitter, ITwitterAPI } from "@hexademo/domain";

namespace AppLoader {
	const sessionAdapter = new SessionCookieAdapter();
	const authenticationAdater = new AuthenticationInMemAdapter();

	// nous avons le choix, en fonction de nos besoins
	// nous pouvons choisir le IndexedDBAdapter, InMemAdapter ou bien RestAdapter
	const tweetAdapter = new TweetIndexedDbAdapter();
	// const tweetAdapter = new TweetInMemAdapter();
	// const tweetAdapter = new TweetRestAdapter();

	const accountServiceAPI = new Account(authenticationAdater, sessionAdapter);
	const twitterServiceAPI = new Twitter(accountServiceAPI, tweetAdapter, tweetEventsDispatcher);

// Les instances API sont exposées, pour une utilisation dans un framework front
	export function getTwitterInstance(): ITwitterAPI {
		return twitterServiceAPI;
	}

💡 ️Si le domaine devient complexe, l’integration d’un moteur de injection de dépendances serait un choix judicieux. Nous avons utilisé l’injection manuelle (aka poor man’s injection) pour garder la simplicité de l’exemple.

Branchement du domaine dans le framework

Avec cette structure, notre hexagone peut être intégré depuis n’importe quel client, qu’il soit codé en vanilla JS ou à l’aide d’un framework front du marché.

Pour mieux montrer la flexibilité du domain, nous avons créé 4 applications en utilisant les frameworks les plus utilisés du marché:

  • AngularJS (exemple basé sur notre application legacy de départ, mais framework encore très utilisé en production)
  • VueJS
  • Angular
  • React

Le package @hexademo/loader se limite simplement à exposer une instance du domain.

Vu que chaque framework peut avoir sa propre façon d’injecter ses variables/dépendances, la responsabilité du loader s’arrête ici.

En fonction du framework utilisé, il faudra consulter la documentation correspondante pour injecter le domain.

Exemple pour une application React

Récupération de les instances de domain à l’aide du loader dans notre App.tsx.

import { AppLoader } from "@hexademo/loader";

const twitterAPI = AppLoader.getTwitterInstance();
const accountAPI = AppLoader.getAccountInstance();

On passe ensuite les instances aux composants qui vont appeler la couche domain.

<HomeView accountAPI={accountInstance} twitterAPI={twitterInstance} />

Le composant peut ainsi utiliser les méthodes du domain.

type HomeViewProps = {
	twitterAPI: ITwitterAPI;
	accountAPI: IAccountAPI;
};

function HomeView(props: HomeViewProps) {
	/**
	 * Get tweets
	 *
	 * @returns {Promise<void>}
	 */
	async function listTweets() {
		const resp = await props.twitterAPI.listTweets();
		await setTweets(resp);
	}
}

Exemple pour une application VueJS

Et l’injection dans notre application legacy AngularJS ?

La beauté de ce découpage c’est que nous pouvons même le faire fonctionner avec notre vieille application legacy AngularJS !

Tout d’abord on récupère les instances de domain via le loader comme pour l’application React.

Cette fois nous utilisons les constantes angularjs pour rendre les instances accessibles au travers de notre application.

import angular from "angular";
import { AppLoader } from "@hexademo/loader";

const accountAPI = AppLoader.getAccountInstance();
const twitterAPI = AppLoader.getTwitterInstance();

// Pour une meilleur organisation, le domain est déclaré dans un module angularjs indépendant.
export default angular
	.module("domain", [])
	.constant("accountAPI", accountAPI)
	.constant("twitterAPI", twitterAPI).name;
import angular from "angular";
import domain from "@/modules/domain/domain.module";

// le domain est une dependance du module "myApp"
// "myApp" aura accès à toutes les instances du domain
angular.module("myApp", [domain]);

De cette façon les instances du domain peuvent être injectées à la demande

class HomeController implements IHomeController {
	tweets: Tweet[] = [];

	constructor(private twitterAPI: ITwitterAPI) {}

	async getTweets() {
		const tweets = await this.twitterAPI.listTweets();
	}
}

Pour résumer, même si chaque technologie/framework implémente sa propre façon d’injecter ses dépendances, le loader et l’hexagone restent des briques indépendantes, sans lien avec la librairie ou framework utilisé dans l’application.

Comment utiliser un domaine métier depuis un autre domaine métier ?

Tout simplement en déclarant la dépendance dans la constructeur du domaine en utilisant l’interface d’API en question.

Cas d’usage : Je souhaite utiliser mon domaine compte dans le domaine twitter pour récupérer le nom de l’utilisateur connecté.

On déclare l’API account dans le constructeur de la classe Twitter


class Twitter implements ITwitterAPI {
	accountAPI: IAccountAPI;

	tweetAdapter: ITweetAdapter;

	constructor(
		accountAPI: IAccountAPI,
		tweetAdapter: ITweetAdapter,
	) {
		this.accountAPI = accountAPI;
		this.tweetAdapter = tweetAdapter;
	}

Une fois déclaré, je peux l’utiliser dans le code de la classe.

async tweet(message: string): Promise<Tweet> {
	this.#checkThatMessageIsFilled(message);
	this.#checkTweetLength(message);

	const author = this.accountAPI.getUsername();
	this.#checkThatAutorIsFilled(author);

	const tweet = await this.tweetAdapter.createTweet({ message, author });
	return tweet;
}

Comment gérer des évènements métier ?

Certains évènements applicatifs ont du sens à être sortis de la couche framework pour être intégrés dans la couche domaine métier.

Dans notre cas nous avons identifié l’évènement tweet-created comme bon candidat pour l’expérimenter.

event

Pour cela nous allons ajouter un nouveau adapter de type dispatcher (adapter chargé d’envoyer des messages, évènements …).

Dans notre cas, nous allons utiliser les custom events nativement supportés dans le navigateur pour émettre nos évènements.

import { ITweetDispatcher, Tweet } from "@hexademo/domain";

class TweetEventsDispatcher implements ITweetDispatcher {
	emitTweetCreated(tweet: Tweet): void {
		const event = new CustomEvent("tweetCreated", {
			detail: tweet,
		});
		document.dispatchEvent(event);
	}
}

export { TweetEventsDispatcher };

Puis nous allons l’ajouter dans notre classe métier Twitter :

class Twitter implements ITwitterAPI {
	accountAPI: IAccountAPI;

	tweetAdapter: ITweetAdapter;

	tweetDispatcher: ITweetDispatcher;

	constructor(
		accountAPI: IAccountAPI,
		tweetAdapter: ITweetAdapter,
		tweetDispatcher: ITweetDispatcher
	) {
		this.accountAPI = accountAPI;
		this.tweetAdapter = tweetAdapter;
		this.tweetDispatcher = tweetDispatcher;
	}
	...

Nous pouvons maintenant l’utiliser dans la méthode tweet() :

async tweet(message: string): Promise<Tweet> {
	this.#checkThatMessageIsFilled(message);
	this.#checkTweetLength(message);

	...

	const tweet = await this.tweetAdapter.createTweet({ message, author });

	// Emission de l'évènement
	this.tweetDispatcher.emitTweetCreated(tweet);

	return tweet;
}

Et le consommer depuis node code côté framework 🙂

// Rafraîchir la liste des tweets lorsqu'un nouveau tweet est créé
document.addEventListener("tweetCreated", refresh);

Comment gérer la persistance (session, cookies …) ?

Sessions, JWT, préférences de l’utilisateur connecté, les informations que l’on souhaite persister côté client peuvent être variées.

Pour cela, nous avons à disposition différentes techniques telles que le local storage, les cookies ou plus récemment l’API IndexedDB du navigateur.

Nous pouvons considérer que c’est le rôle du métier de notre application de piloter la persistance des données.

La persistance de données sera quand à elle de la responsabilité de la couche adapter.

Pour cela comme pour la consommation de données, nous allons créer une SPI que nous nommerons ISessionAdapter.

Cette interface nous servira à définir les méthodes de sessions.

Dans notre cas, l’interface est la suivante :

interface ISessionAdapter {
	storeValue(key: string, value: string, duration: number): void;
	getValue(key: string): string;
	flush(): void;
}

export type { ISessionAdapter };

Nous pouvons maintenant implémenter cette interface dans notre couche adapter.

Voici par exemple une implémentation de session avec stockage dans les cookies du navigateur.

session-cookie-adapter.ts
import { ISessionAdapter } from "@hexademo/domain";

class SessionCookieAdapter implements ISessionAdapter {
	storeValue(key: string, value: string, duration: number): void {
		document.cookie = `${key}=${value}; path=/; max-age=${duration}; SameSite=Strict`;
	}

	getValue(key: string): string {
		const value = `; ${document.cookie}`;
		const parts = value.split(`; ${key}=`);
		return parts.pop()?.split(";").shift() as string;
	}

	flush(): void {
		const cookies = document.cookie.split(";");

		for (const cookie of cookies) {
			const eqPos = cookie.indexOf("=");
			const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie;
			document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT`;
		}
	}
}

export { SessionCookieAdapter };

Si on souhaite dans le futur changer pour du localstorage ou du Indexed DB, il suffira d’écrire l’adapter correspondant et de le charger à la place de l’adapter précédemment injecté dans le domaine.

Utilisation des web components

Après avoir éprouvé la côté agnostique du domain, et branché des applications écrites dans différents frameworks, le constat arrive rapidement…

⚠️ Mais… si dans le futur je change de framework, mes écrans seront dépréciés aussi non ?

C’est une très bonne remarque, et la réponse est un grand oui 😕

Le but étant de séparer le métier de la technologie/framework utilisé et surtout de limiter les impacts d’un changement de framework, nous avons encore ce dernier mur à franchir, les interfaces.

Les interfaces clés, peuvent aussi être considérées comme une surcouche du métier.

La solution : utiliser les Web components 🪄 (aka custom elements)

Les web components, permettent de créer des composants standard, intégrables dans n’importe écran qu’il soit réalisé à l’aide d’un framework, ou même en vanilla js/html.

Pour la création de web components, même si l’écriture en vanilla js reste une possibilité, nous avons fait le choix de le faire via un framework dédié, qui résoudra des nombreux problèmes potentiels d’intégration/bundling. Le choix du framework sera fait en fonction de différents facteurs qui ne font pas partie du scope de cet article.

Voici à quoi ressemble le découpage d’une application frontend une fois la composante web components ajoutée :

webcomp

Pour notre application, nous avons identifié les composants suivants comme externalisables sous la forme de web components :

  • Le formulaire de connexion / création de compte
  • Le composant de création de tweet
  • Le composant d’affichage de tweet

Et voici les web components associés :

Tests End-to-End (E2E) avec Playwright

Les tests end-to-end (ou E2E) ont pour but de tester notre application de bout en bout.

Pour ce projet, nous avons décidé d’utiliser Playwright pour réaliser nos tests sur différents navigateurs.

Playwright est un framework de test E2E compatible avec tous les OS.

Il supporte Chromium, WebKit et Firefox ce qui permet d’exécuter des tests sur les principaux navigateurs du marché.

Pour une présentation détaillée, vous pouvez regarder l’excellente vidéo de Grafikart sur le sujet : https://www.youtube.com/watch?v=UgF2LwlNnC8

Les différentes options du framework (navigateurs, serveur à lancer, screenshot …) sont configurables depuis le fichier playwright.config.ts.

Exemple de test E2E

Pour notre application, nous avons écrit un scénario de test simple :

  • Un utilisateur existant se connecte à notre application
  • L’utilisateur poste un tweet
  • L’utilisateur like un tweet
  • L’utilisateur se déconnecte

Et voici ce que ça donne au niveau code :

e2e/tests/existing-user.spec.ts
import { test, expect } from "@playwright/test";

test("should login with unicorn account, like, post a message and disconnect", async ({
	page,
}) => {
	// login
	await page.goto("http://localhost:5173");
	await expect(page).toHaveURL("http://localhost:5173/#/signin");
	await page.locator("#username").click();
	await page.locator("#username").fill("unicorn");
	await page.locator("#password").click();
	await page.locator("#password").fill("rainbow");
	await page.locator("text=Login").click();
	await expect(page).toHaveURL("http://localhost:5173/#/home");

	// create a tweet
	await page.locator("#message").click();
	await page.locator("#message").fill("hello world !");
	await page.locator("text=Honk 🚀").click();
	const newTweet = await page.locator(
		"tweet-card:first-of-type .tweet-card__like-button"
	);
	await expect(newTweet).toHaveText("0 ❤️");

	// like a tweet
	const likes = await page.locator(":nth-of-type(3) .tweet-card__like-button");
	await expect(likes).toHaveText("3 ❤️");
	await likes.click();
	await expect(likes).toHaveText("4 ❤️");

	// logout
	await page.locator("text=Logout").click();
	await expect(page).toHaveURL("http://localhost:5173/#/signin");
});

Rapport de test

Une fois les tests terminés, la commande npx playwright show-report permet de consulter le rapport de tests.

Exemple de rapport OK

rapport

En cas d’erreur, il est également possible de consulter le mode trace qui permet de visualiser le rendu navigateur au moment de l’erreur.

rapportko rapportko2

Intégration des tests dans Gitlab CI

Dans notre cas, nous avons intégré nos tests E2E dans Gitlab CI pour tester nos différentes implémentations de framework.

pipeline

Ce pipeline nous permet de passer la même suite de tests sur notre application legacy angularjs ainsi que sur les refontes en Vuejs, React et Angular.

Le code du pipeline est disponible ici : https://gitlab.com/thekitchen/frontend-hexagonal-demo/-/blob/main/.gitlab-ci.yml

Projet final

Le projet terminé est disponible ici : https://gitlab.com/thekitchen/frontend-hexagonal-demo

Conclusion

En conclusion, la réalisation de ce projet nous a permis de monter en compétence sur la construction et le découpage des applications front.

La mise en pratique de l’architecture hexagonale côté frontend permet de construire des applications durables dont le métier pourra perdurer même après dépréciation d’un framework UI.

Avec ce découpage, il est également possible d’intégrer des développeurs back au développement des applications sur la partie domaine et adapter qui reste identique à du Javascript backend.

Et pour finir, le fait que notre application devienne testable sans backend et uniquement en mémoire à l’aide des stubs nous a facilité le déploiement pour réaliser les tests end-to-end.

Si vous avez des questions sur le code ou sur la réalisation du projet, n’hésitez pas à nous contacter !

Merci pour votre attention 🙇‍♂️.

Matthieu et Sebastian

Liens