header

Desde la llegada de los frameworks frontend y la rápida crecida de las aplicaciones SPA (Single Page Application), la volatilidad del ecosistema frontend y la adherencia del negocio con el framework, hacen que hoy sea difícil producir aplicaciones robustas y durables en el tiempo. El objetivo de este articulo es de mostrar como la arquitectura hexagonal frontend puede dar una solución a este problema.

Presentación

Matthieu

Arquitecto y desarrollador backend, desarrollo principalmente en Java, JS, Go. Actualmente, comienzo a descubrir Rust.

Me encanta todo lo relacionado con la CI/CD (Gitlab 🦊 ❤️), imaginar y crear aplicaciones robustas, eficientes y ligeras en recursos.

Comparto mis descubrimientos a travez de articulos de blog.

Cuando no estoy en mi escritorio jugando con mi impresora 3D, me pueden encontrar en el agua 🛶 !

    

Sebastián

Arquitecto y desarrollador backend.

Antiguo desarrollador C#, hasta dejarme seducir por el open source y NodeJS. Actualmente en una relación de amor y odio con Rust(yo doy amor pero no estoy seguro de que sea recíproco 😅).

Linux ❤️ es mi pastor y nada me faltara. Como Matthieu, soy un apasionado de la CI/CD (Gitlab 🦊 gang), me gusta optimizar/automatizar todo lo que sea optimizable/automatizable.

Me la paso investigando más tiempo del que debería, o quizás es simplement que el día es demasiado corto.

  

Agradecimientos

Gracias a Simon Duhem pour su ayuda con el estilo y los web components.

Gracias a Julien Topçu que nos aconsejó y ayudó sobre nuestras dudas relacionadas con la arquitectura hexagonal.

Situatión y contexto

Hoy en día, la mayoría de las aplicaciones frontend sont creadas con la ayuda de frameworks.

Los tres principales frameworks del mercado son: React, VueJS et Angular.

Los frameworkds UI permiton desarrollar más rápidamente las aplicaciones. Manejan nativamente la reactividad, como también la compatibilidad con los diferentes navegadores.

Problemáticas

Todo el codigo dedicado a la parte negocio frontend va a estar generalmente acoplado al funcionamiento del framework.

Cuando un framework se deprecia, es necesario reescribir el código de toda la aplicación en un nuevo framework.

Zoom sobre el caso AngularJS

2009 : Google libera la primera versión de AngularJS.

El framework va a volverse extremadamemte popular, y muchos desarrolladores van a realizar sus aplicaciones SPA con la ayuda de este framework.

2018 : El équipo anuncia el fin del desarrollo de AngularJS, y el fin de la mantención para el 31 de diciembre 2021.

Luego de este anuncio los equipos encargados de mantener aplicaciones AngularJS se encuentras confrontados a una decisión para mantener sus aplicaciones:

  • Migrar de AngularJS hacis Angular ?
  • Reescribir la aplicaciones en otro Réécrire l’application dans un autre framework ?
  • Garder son code legacy et croiser les doigts pour que personne ne trouve de faille ?

La solución retenida pasara sin dudas por una reescritura importante del código, incluido todo lo que corresponde al negocio frontend de la aplicación.

Cómo evitarlo?

Para evitar de caer en esta trampa, hay que buscar la forma de separar el negocio frontend de la tecnología aportada por el framework.

La idea es simple: construir unaa applicación donde, por una parte el framework se encarga solamente del render html y de la reactividad de components, y por otra parte, el negocio frontend está aislado y escrito con un código agnóstico del framework.

Ventajas:

  • El código ligado al framework está aislado
  • Nuestro código negocio es durable en el tiempo, porque es agnóstico

En teoría la idea parece simple, pero cómo llevarlo a la práctica?

La arquitectura hexagonal al servicio del frontend

La arquitecture hexagonal es un patrón de arquitecture creado por Alistair Cockburn que posiciona a la capa de negocio al centro de la aplicación (hexágono), garantizando al mismo tiempo una adherencia mínima con la capa técnica (o de infraestructura)

Conceptos de base:

  • El dominio de negocio es agnóstico y depende sólo de él mismo.
  • El aislamiento del dominio de negocio está garantizado via un sistema de puertos (interfaces).
  • La capa de adapters circundantes alrededor del hexágono deben respectar las interfaces definidas en la capa de puertos para comunicar con el dominio.

Para ir mas lejos sobre la arquitectura hexagonal, ver los recursos en la section vinculos.

He aquí un ejemplo de organizacion de una aplicación frontend utilsando la arquitectura hexagonal

schema

Proyecto de inicio

Las diferentes etapas a seguir se basarán sobre la migración de una aplicación legacy hacia un modelo hexagonal.

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

Esta aplicacion es un twitter simplificado, que contiene las caracteristicas siguientes:

  • autentificatión
  • creación de cuenta
  • creación de tweets
  • visionage de tweets
  • like

Ha sido escrita en AngularJS con el objetivo de ponerse en el caso de una aplicacion escrita en un framework depreciado.

Objetivo

El objetivo del articulo es de mostrar paso a paso cómo comenzar desde un proyecto legacy para llegar a una organizacion en arquitectura hexagonal, permitiendonos fácilmente cambiar el framework de nuestra aplicación.

No será una receta mágica aplicable sobre todas las aplicaciones, sino un ejemplo de las étapas en el proceso de migración, resultado de nuestra investigación.

Organización del nuevo proyecto

Un proyecto de este tipo necesita de algunos pre-requisitos técnicos para permitir un desarrollo fluido y sereno, además de aporter un aislamiento estricto de las partes técnicas asociadas al dominio.

Para concentrarse puramente en el código, y no en los utiles alrededor del proyecto, consideramos que el uso de un monorepo es una necesidad.

Evidentemente, la elección del gestion de paquetes es secundaria. Nosotros hemos elegido trabajar con pnpm.

La organización jerárquica del monorepo es la siguiente:

.
├── 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

Los detalles relacionados a cada una de las carpetas, serán explicadas conforme avanza el artículo.

Identificar el negocio

El negocio de la aplicación esta compuesto de 2 partes:

  • Gestión de cuentas
  • Gestión de tweets

Para las cuentas, la aplicación permite:

  • crear una cuenta
  • autentificarse
  • desconectarse
  • saber si el usuario está autentificado
  • recuperar el jwt del usuario autentificado
  • recuperar el nombre del usuario autentificado

Para los tweets, la aplicación permite:

  • creación de tweets
  • recuperar los tweets
  • like de tweets

Dominio - Creación de interfaces API

Una vez nuestro negocio identificado, podemos crear las interfaces de nuestro dominio.

La capa API contiene todas las interfaces que permiten comunicarse con el dominio del negocio.

Esta capa se expone desde el dominio, esto permite garantizar su aislamiento.

schema

API Cuentas

Aqui podemos ver la API creada a partir del negocio de cuentras descrito anteriormente

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

Aqui la interface API creada a partir de negocio de la parte tweet

ports/api/twitter.ts

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

export type { ITwitterAPI };

Dominio - Creación de las interfaces SPI

La capa SPI (Service Provider Interface) contiene todas las interfaces requeridas y provistas por el dominio para interactuar con los datos.

Aquí vamos a defini las interfaces que permiten al dominio de recuperar / crear tweets, autentificarse, etc…

Estas interfaces seran implementadas por la capa 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>;
}

Dominio - Escritura del código del negocio

Ahora que nuestras interfaces API y SPI están escritas, vamos a pasar a la escritura del código del negocio.

Negocio de cuentas

Para las cuentas, tenemos las siguientes reglas de negocio a implementar:

No es posible crear una cuenta sin usuario / contraseña

No es posible autentificarse con una contraseña vacía

El token debe ser conservado luego de la autentificacion

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 };

Negocio de los tweets

Para los tweets, tenemos las reglas de negocio siguientes en el momento de la creación de un tweet:

No es posible crear un tweet vacío

No es posible crear un tweet sin autor

Un tweet no debe contener más de 144 caracteres

Para comenzar, vamos a crear un tipo Tweet con los atributos necesarios por nuestro negocio de tweets front.

⚠️ Este tipo, no debe necesariamente corresponder al formato enviado por nuestro backend. Es la representación de la entidad de negocio de nuestro frontend.

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

export type { Tweet };

Podemos pasar enseguida a nuestras reglas de negocio:

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 };

Dominio - Stubs

Para testear el código del negocio sin la necesidad de desplegar el backend asociado, escribiremos stubs de los adapters. Estos adapters serán inyectados en lugar de los adapters reales.

Un stub es una técnica empleada para aislar una parte del código con el fin de hacerla autónoma. En nuestro caso, los stubs serán implementaciones en memoria que retornarán un dato ficticio.

Algunos puntos importantes a conocer en nuestro caso particular:

  • los stubs deben implementar las interfaces SPI, y por lo tanto, respetar las firmas de los métodos.
  • para garantizar el aislamiento del dominio, los stub serán creados dentro del dominio.
  • un buen stub debe retornar el conjunto de datos necesarios para testear todos los casos del negocio.

En nuestro proyecto, hemos situado los stubs dentro de una carpeta stubs, al mismo niveal de las 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 };

Test del dominio de negocio

Ahora que tenemos stubs, podemos fácilmente testear nuestro dominio de negocio.

A diferencia de los test frotend habituales, nuestra organizacion en arquitectura hexagonal nos permite testear directamente nuestro dominio, sin necesidad de montar componentes UI.

⚠️ : por lo anterior, no debemos entender que los tests de components UI son inútiles, sino que esta organización nos permite realizar nuevos tipos de test sobre nuestra aplicación front. **Considerando que nuestro negocio está separado del framework, los test de nuestro dominio (reglas de negocio) son independientes.

Ejemplo de tests de negocio:

Instancia de la clase Twitter con adapters stubs:

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

Test de la regla sobre el número de caracteres:

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 del like de 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);
});

Todos los tests de la applicación se pueden encontrar junto a los ficheros del negocio *.spec.ts: https://gitlab.com/thekitchen/frontend-hexagonal-demo/-/tree/main/packages/domain/src

Escritura de adapters

Ahora que nuestro negocio está escrito y testeado, podemos pasar a la creación de la capa adapter.

La capa adapter de nuestro hexágono implementa las interfaces de tipo SPI.

Esta capa es responsable de interactuar con la capa de datos, generalmente via llamadas API (REST, GraphQL, etc…).

En nuestro caso, la capa adapter es responsable de las llamadas a nuestro backend que expone una API REST.

Como en el caso del negocio, hemos creado 2 adapters. Uno responsable de llamar a la API de cuentas, y el otro de llamar a la API de tweets.

Como se vió anteriormente en la parte SPI, estos adapters deben implementar las interfaces definidas en la capa SPI del dominio.

Aqui se pueden ver los adapters utilizados para comunicarse con nuestra 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 };

La ventaja de esta técnica es que nos garantiza la capacidad de evolución de la capa de comunicación con nuestro backend.

Si en el futuro deseamos utilisar una API GraphQL, u otra API externa para recuperar los datos. Sólo necesitamos crear un nuevo adapter para esta tecnnología.

La clave es que es el dominio el que determina el contrato de interface (entradas y salidas) y que la capa adapter respeta este contrato.

Montar el hexágono

Para instanciar el hexágono, necesitamos “conectar” los adapters a los “ports spi” del dominio.

Desde un punto de vista técnico, hay que inyectar las dependencias (adapters) via los constructores existentes en nuestra capa dominio.

Para facilitar esta orquestación, hemos decidido crear un paquete utilitaria llamado @hexademo/loader. Este paquete se encarga de instanciar las clases en el orden correcto.

Aquí podemos ver el código de nuestro 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();

  // tenemos le elección, en función de lo que necesitemos usar
	// podemos elegir entre IndexedDBAdapter, InMemAdapter o 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);

// Las instancias API están expuestas, para un uso framework front.
	export function getTwitterInstance(): ITwitterAPI {
		return twitterServiceAPI;
	}

💡 ️Si el dominio se vuelve complejo, la integración de un motor de inyección de dependencias seria una elección judiciosa. En el código del artículo, hemos utilizado la inyección manual (aka poor man’s injection) para mantener el ejemplo simple.

Inyectar el dominio en el framework

Con esta estructura, nuestro hexágono puede ser integrado dentro de cualquier cliente, tanto si esta escrito en vanilla JS o via un framework front.

Para demostrar la flexibilidad del dominio, hemos creado 4 aplicaciones utilisando los frameworks más populares del mercado:

  • AngularJS (ejemplo basado en nuestra aplicación legacy inicial, sim embargo, AngularJS es aún un framework con un alto uso en producción)
  • VueJS
  • Angular
  • React

Le package @hexademo/loader se limite simplement à exposer une instance du domain. El paquete @hexademo/loader se limita simplemente a exponse une instancia del dominio.

Visto que cada framework puede tener su propia forma de inyectar sus variables/dependencies, la responsabilidad del paquete loader llega hasta aquí.

Dependiendo del framework usado, es necesario consultar la documentación correspondiente para encontrar la mejor forma de inyectar el dominio.

Ejemplo de una aplicación React

Recuperación de las instances de dominio con la ayuda de un loader en nuestro Àpp.tsx`.

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

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

Pasamos enseguida las instancias a los componentes que van a llamar a la capa dominio.

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

El componente puede, de esta forma, utilisar los métodos del dominio.

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);
  }
}

Ejemplo de una aplicación VueJS

Y la inyección en nuestra aplicacion legacy AngularJS?

Lo interesante de esta arquitectura, es que podemos incluso hacerla funciona con una antigua aplicación AngularJS !

Primero, recuperamos las instancias del dominio via el loader, tal como se hizo en la aplicación React.

Esta vez, usaremos las constantes AngularJS para que las instancias sean accesibles via el motor de inyección de dependencias de AngularJS.

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

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

// Para una mejor organización, el dominio es declarado en un módulo AngularJS independiente.
export default angular
  .module("domain", [])
  .constant("accountAPI", accountAPI)
  .constant("twitterAPI", twitterAPI).name;
import angular from "angular";
import domain from "@/modules/domain/domain.module";

// el dominio es una dependencia del módulo "myApp"
// "myApp" ahora tiene acceso a todas las instancias expuestas por el dominio
angular.module("myApp", [domain]);

De esta forma, las instancias del dominio pueden ser inyectadas, a la demanda.

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

	constructor(private twitterAPI: ITwitterAPI) {}

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

Para resumir, incluso si cada “tecnología/framework** implementa su propia lógica para inyectar sus dependencies, el loader y el hexágono siguen siendo **bloques independientes**, sin vínculo con la librería o framework usado en la aplicación.

Cómo usar un dominio de negocio desde otro dominio de negocio?

Simplemente declarando la dependencia en el constructor del dominio, usando la interface de la API correspondiente.

Caso de uso: Necesito usar mi dominio “account” en el dominio “twitter” para recuperar el nombre del usuario conectado.

Declaramos la API “account” en el constructor de la clase Twitter


class Twitter implements ITwitterAPI {
	accountAPI: IAccountAPI;

	tweetAdapter: ITweetAdapter;

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

Una vez declarada, puedo usarlo en el código de la clase.

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;
}

Cómo gestionar eventos de negocio?

Algunos eventos de aplicación tiene vocación a ser extraídos de la capa del framework, para ser integrados en la capa del dominio de negocio.

En nuestro caso, identificamos el evento tweet-created como un buen candidato para experimentar.

event

Para ello, vamos a agregar un nuevo adapter de tipo dispatcher (adapter encargado de enviar mensajes, eventos, etc…).

En nuestro caso, usaremos la api de custom events, nativamente soportada en el navegador.

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 };

Luego, lo agregamos en nuestra clase de negocio 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;
	}
	...

Ahora podemos usar el método 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;
}

Y consumirlo desde nuestro código en el lado del framework 🙂

// Actualizar la lista de tweets cuando un nuevo tweet ha sido creado

document.addEventListener("tweetCreated", refresh);

Cómo gestionar la persistencia (sesión, cookies, etc…)?

Sesiones, JWT, preferencias del usuario conectado, las informaciones que deseamos persistir en el lado cliente pueden ser de diverso tipo.

Para esto, tenemos a disposición diferentes técnicas como el local storage, las cookies o más reciente la API IndexedDB del navegador.

Podemos considerar que es el rol del negocio de nuestra aplicación el de gestionar la persistencia de datos.

La persistencia de datos será hecha en la capa adapter.

Para ello y para el consumo de datos, vamos a crear una SPI que nombraremos ISessionAdapter.

Esta interface nos servirá para definir los métodos de sesión.

En nuestro casto, la interface es la siguiente:

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

export type { ISessionAdapter };

Podemos ahora implementar esta interface en nuestra capa adapter.

Aquí por ejemplo, una implementación de sesión con persistencia usando los cookies del navegador.

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 deseamos en el futuro cambiar por localStore o IndexedDB, bastará simplemente de escribir el adapter correspondiente y de cargarlo en lugar del adapter anteriormente inyectado en el dominio.

Uso de web componentes

Luego de haber logrado hacer un dominio agnóstico del renderizado, y haber hecho la prueba con aplicaciones escritas en diversos frameworks, la gran pregunta se presenta rápidamente…

⚠️ Pero…si en el futuro decido cambiar de framework, mis interfaces de usuario también estarán depreciadas no?

Es una muy buena pregunta, y la respuesta es un gran si 😕

Considerando que el objetivo es separar el negocio de la tecnologia/framework usado, y sobretodo de limitar los impactos de un cambio de framework, tenemos aún una última barrera a superar, las interfaces de usuario.

Las interfaces claves, pueden también ser consideradas como una capa del negocio.

La solución: usar web components 🪄 (aka custom elements)

Los web components, permiten crear componentes standard, integrables en cualquier interface, haya sido creada mediante un framework, o simplemente en vanilla js/html.

Para la creación de web components, incluso si la escritura en vanilla js es una opción, decidimos hacerlo via un framework dedicado, que resolverá numerosos problemas potenciales de integración/bundling. La elección de otro framework será hecha basada en factores ligados al contexto de cada équipo/proyecto/etc. Estos factores van más allá del contexto de este artículo.

Aquí un diagrama de la organización de la aplicación frontend una vez que los web components han sido integrados:

webcomp

Para nuestra aplicación, hemos identificado los componentes listados a continuación, como externalisables en forma de web component:

  • El formulario de conexión / creación de cuenta
  • El componente de creación de tweet
  • El componente de detalle de un tweet

Y he aquí el código de los web components asociados:

Tests End-to-End (E2E) con Playwright

Los tests end-to-end (o E2E) tienen como objetivo testear nuestra aplicación de principio a fin.

Para este proyecto, hemos decidido usar Playwright para realizar nuestros tests en diferentes navegadores.

Playwright es un framework de test E2E compatible con todos los OS.

Soporte Chromium, Webkit y Firefox, lo que permite ejecutar los tests en los principales navegadores del mercado.

Para una presentación detallada, se puede ver la excelente presentación de Debbie O’brien (en español)

Las diferentes options del framework (navegadores, servidor a ejecutar, screenshot…) son configurables desde el fichero playwright.config.ts.

Ejemplo de test E2E

Para nuestra aplicación, hemos escrito un escenario 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

  • Un usuario existente se connecta a nuestra aplicación

  • El usuario crea un tweet

  • El usuario like un tweet

  • El usuario se desconecta

Y aquí el resultado traducido en código:

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");
});

Reporte de test

Una vez los tests terminados, el comando npx playwright show-report permite consultar el reporte de tests.

Ejemplo de reporte OK

rapport

En caso de error, es posible consultar el modo trace que permite visualizar el renderizado del navegador al momento del error.

rapportko rapportko2

Integración de tests en Gitlab CI

En nuestro caso, hemos integrado nuestros tests E2E en Gitlab CI, con el fin de testear las diferentes implementaciones de framework.

pipeline

Este pipeline nos permite pasar el mismo conjunto de tests en nuestras aplicaciones, poco importa si es angularjs, Vuejs, React o Angular.

El código del pipeline está disponible aquí: https://gitlab.com/thekitchen/frontend-hexagonal-demo/-/blob/main/.gitlab-ci.yml

Proyecto final

El proyecto terminado está disponible aquí: https://gitlab.com/thekitchen/frontend-hexagonal-demo

Conclusión

Como conclusión, la realización de este proyecto nos permitió mejorar nuestros conocimientos sobre la construcción y la organización de las aplicaciones frontend.

La implementación de la arquitecture hexagonal en el frontend, permite construir aplicaciones duraderas en el tiempo, donde el código de negocio puede perdurar, incluso son la depreciación de un framework UI.

Con esta organización, además es posible integrar en el frontend, desarrolladores con una experiencia puramente backend . Todo esto gracias a que la capa dominio y adapter, utiliza Javascript nativo.

Y para terminar, el hecho que nuestra aplicación sea testeable sin backend y unicamente en memoria gracias a stubs, nos facilita el despliege para realizar los tests end-to-end.

Pour cualquier pregunta que se presente, no duden en contactarnos !

Gracias por la atención 🙇‍♂️.

Matthieu y Sebastián

Vínculos