Arquitectura CLEAN para el frontend

Implementando CLEAN en aplicaciones web con React

Publicado: 10/10/2021 · Tiempo de lectura: 14 minutos

👇🏼

Este artículo es una traducción del documento revisado en el siguiente video.

Sobre la importancia de usar capas

Separar el software en capas no es un concepto nuevo. Ha sido parte de la industria por muchos años y fue uno de los primeros estilos de arquitectura creados. En resumen, este estilo no es más que dividir las responsabilidades de tu aplicación en distintos capas, como en un pastel, donde las capas superiores puede requerir a las capas inferiores, pero no al revés.

Las capas interactúan por medio de fachadas, así que mientras las APIs públicas entre cada capa sean respetadas, una capa no debería saber nada sobre la implementación interna del resto.

Demos un vistazo al siguiente diagrama:

Diagrama de arquitectura en capas
Diagrama de arquitectura en capas.

La arquitectura de capas más común está compuesta de tres capas: UI, Dominio e Infraestructura. Nuestros sistemas pueden tener cuantas capas sea necesario, no están limitados solamente a tres, solo sucede que esta es la configuración más común.

Si aplicamos esta organización en una aplicación de React, nos encontraríamos con nuestros componentes visuales en la capa superior. Luego nuestra solución de manejo de estado va en la capa siguiente. Finalmente la capa base está dedicada a infraestructura, particularmente para los componentes de software que se encargan de comunicarnos con recursos externos, como un backend, una base de datos de firebase, un servicio de notificaciones, algún mecanismos de almacenamiento local y cualquier otra fuente de información.

Para una aplicación pequeña esto es suficiente, y posiblemente es como hemos estado escribiendo la mayor parte de nuestras aplicaciones en React. Pero en cuanto las aplicaciones comienzan a crecer, estas capas comienzan a tener muchas responsabilidades y lidiar con muchas cosas, lo que las hace difícil de razonar al respecto y por lo tanto, difícil de mantener.

Antes de ver cómo resolver estos problemas, veamos cuáles son los beneficios de dividir nuestras aplicaciones en capas y por qué es valioso explorar este tipo de arquitectura para implementarla en nuestros proyectos.

Facilidad de razonamiento

Dividir y conquistar, la mejor forma de resolver un problema grande es separándole en problemas más pequeños, que son más fáciles de resolver (o al menos de pensar en ellos). Podemos razonar sobre tuna capa de manera independiente, sin necesidad de preocuparnos por la implementación de otras capas.

Substitución

Las capas pueden ser substituidas fácilmente con implementaciones alternativas. En la práctica no es como que cambiemos nuestra librería de http todos los días, pero cuando llegue el momento, el cambio está contenido dentro de las barreras de la capa y debería ser transparente para el resto de las capas. Refactorizar se vuelve menos invasivo y más abordable.

Evolución

Para que una arquitectura escale, debe ser capaz de cambiar en la medida que el software madure y aparezcan nuevos requerimientos. Si bien queremos hacer algo de diseño previamente al desarrollo, ciertas cosas sólo apareceran en nuestro radar luego de haber comenzado a desarrollar. Cuando utilizamos capas, podemos postergar ciertas decisiones sobre detalles de implementación u otras responsabilidades hasta que tengamos suficiente información para tomar una decisión sensata.

Desacoplamiento

Las dependencias entre capas están controladas dado que son unidireccionales. Apuntar por bajo acoplamiento (mientras mantenemos alta cohesión, o colocación) es una buena forma de evitar que nuestra aplicación se convierta en una bola de lodo.

Facilidad para hacer pruebas

Una arquitectura en capas nos permite realizar pruebas unitarias fácilmente. Aunque esto es muy bueno, en mi opinión no es el mayor beneficio en términos de pruebas. Para mí el mayor beneficio es que hace más fácil escribir pruebas mientras implementamos funcionalidad. Como cada capa tiene una responsabilidad bien definida, es mucho más fácil pensar sobre las cosas que nos interesan probar durante la implementación, que una vez que nuestro software esté completo.

Todas las cosas que he mencionado anteriormente nos ayudan a escribir código que es más fácil de mantener. Un base de código mantenible nos hace más productivos ya que gastamos menos tiempo batallando contra deuda técnica y más tiempo trabajando en nuevas funcionalidades. También reduce el riesgo al introducir cambios. Finalmente, hace nuestro código más fácil de probar, y que a larga nos da más confianza durante el proceso de desarrollo y de refactorización.

Ahora que ya conocemos los beneficios de las arquitecturas de capas, hablemos de CLEAN y cómo podemos implementar en React.

Arquitectura CLEAN

La arquitectura CLEAN es un tipo de arquitectura de capas que recopila varias ideas de otras arquitecturas de capas como Arquitectura de Cebolla, Arquitectura Hexagonal, Barrera de Control de Entidad entre otras.

La idea principal detrás de CLEAN es poner el negocio y las entidades del negocio en el centro de un sistema de software, y el resto de las capas envolviendo la capa de entidades. Las capas externas son menos específicas al negocio mientras que las internas sólo tratan de ello.

Describiremos brevemente lo que cada capa hace en la arquitectura CLEAN para entender como podemos rescatar algunos de sus conceptos y aplicarlos en nuestras aplicaciones en React.

Diagrama de arquitectura CLEAN
Diagrama de arquitectura CLEAN.

Entidades

Al centro del diagrama tenemos las entidades. En la arquitectura CLEAN, las entidades son un medio para contener estado relacionado a las reglas del negocio. Las entidades deberían ser estructuras de datos planas y no deberían tener conocimiento del framework de aplicación que estemos utilizando.

Para una aplicación frontend, aquí es donde ponemos toda la lógica relacionada a las entidades del sistema. En general solemos dejar esa responsabilidad en manos de nuestra librería de estado, pero en este caso eso está fuera. Más adelante veremos por qué.

💡

Una librería de manejo de estado solo se encarga del almacenamiento del estado y no de las relaciones entre las entidades de nuestro negocio.

Casos de uso

Un caso de uso es en CLEAN lo que una historia de usuario es en metodologías ágiles. En esta capa es donde las reglas de la aplicación residen. Un caso de uso debería representar algo que un usuario desea realizar, y por ende todo el código relacionado a cumplir el objetivo del usuario debería estar aquí. Dado que esta capa sólo puede depender directamente de la capa de entidades, para poder hacer cosas como consumir datos de una API, debemos inyectar dichas dependencias en nuestros casos de uso aplicando inversión de control.

Controladores y Presentadores

Esta capa contiene código del framework que implementa los casos de uso. Típicamente la capa de UI va a utilizar los métodos expuestos por los controladores o presentadores.

Framework, librerías y conductores

La capa más externa es donde residen todas las operaciones de IO. Acciones del usuario, conexiones http, leer de un mecanismo de almacenamiento, etc. Esta igualmente es la capa de nuestros componentes de interfaz.

Cabe mencionar que tal como en cualquier otra arquitectura de capas, podemos agregar cuantas capas sea necesario. Dicho eso, veamos como estos conceptos se traducen a lo que hacemos en React para implementar esta arquitectura en una aplicación de prueba.

Una simple aplicación de contador

🚨

Implementar una aplicación de estas características con CLEAN en la vida real es disparar a una mosca con un tanque. Esta aplicación es sólo para fines didácticos.

Vamos a ver como aplicar los conceptos de CLEAN implementando una aplicación muy simple que debería verse así:

Una simple aplicación de contador
Una simple aplicación de contador.

Algunos requerimientos de nuestra aplicación:

  • El valor inicial debe venir de una fuente de datos remota
  • El contador no puede ser disminuido cuando su valor es 0
  • El valor del contador debe ser persistido a la fuente remota de forma óptima

Hablaremos a continuación de cada capa de nuestra aplicación:

Entidades

En el centro de nuestro universo se encuentran las entidades del dominio. En este case definiremos una interfaz para Counter como un objeto con una propiedad value de tipo numérico.

Es importante recalcar que así es como vamos a referirnos a la entidad Counter en todo el resto de nuestra aplicación, así que esta definición es canónica en términos de lo que un contador esencialmente es.

// domain/counterEntity.ts
export interface Counter {
value: number;
}

Si bien podríamos haber utilizado una clase para representar nuestro model, una interfaz es más que suficiente.

Modelo del dominio

Según Martin Fowler:

El modelo de un objeto del dominio que incorpora data y comportamiento.

Dentro de nuestro model del dominio podemos definir operaciones sobre nuestras entidades. En este caso podemos crear, aumentar y disminuir el contador.

Notemos que la regla de negocio para que el valor del contador nunca disminuya de cero está definida aquí, muy cerca de la definición de la entidad.

// domain/counterModel.ts
import type { Counter } from "./counterEntity";
const create = (count: Counter["value"]) => ({ value: count });
const decrement = (counter: Counter) => ({
value: Math.max(counter.value - 1, 0)
});
const increment = (counter: Counter) => ({ value: counter.value + 1 });
export { create, decrement, increment };

Podríamos poner la interfaz de la entidad y estas operaciones en el mismo archivo y estaría bien igualmente.

Store (o repositorio)

Esto es lo que comúnmente entendemos por manejo de estado. La diferencia es que aquí solo definimos la forma de nuestra capa de data, no la implementación. Para esto podemos utilizar una interfaz.

// domain/counterStore.ts
import type { Counter } from "./counterEntity";
interface CounterStore {
// State
counter: Counter | undefined;
isLoading: boolean;
isUpdating: boolean;
// Actions
loadInitialCounter(): Promise<Counter>;
setCounter(counter: Counter): void;
updateCounter(counter: Counter): Promise<Counter | undefined>;
}
export type { CounterStore };

Casos de uso

Como ya mencionamos anteriormente, los casos de uso se pueden asociar a historias de usuario o a acciones que el usuario (o que cualquier otro sistema) puede realizar por medio de nuestro sistema.

Nuestra aplicación tiene tres casos de uso:

  • Obtener el valor inicial del contador de nuestra fuente de datos
  • Aumentar el valor del contador
  • Disminuir el valor del contador

Notemos que actualizar el valor del contador en la fuente de datos remota (que es uno de los requerimientos de nuestra aplicación) no es un caso de uso, si no que un efecto secundario de aumentar o disminuir el contador en el cliente. Para esta capa no es relevante que la fuente de datos sea remota.

getCounterUseCase

// useCases/getCounterUseCase.ts
import type { CounterStore } from "../domain/counterStore";
type GetCounterStore = Pick<CounterStore, "loadInitialCounter">;
const getCounterUseCase = (store: GetCounterStore) => {
store.loadInitialCounter();
};
export { getCounterUseCase };

Para este caso hemos definido una interfaz GetCounterStore que solo requiere que el parámetro que le pasemos a nuestro caso de uso, tenga el método loadInitialCounter. Si bien CounterStore define más propiedades y métodos, aquí solo nos importa que dicho método exista.

incrementCounterUseCase

// useCases/incrementCounterUseCase.ts
import { updateCounterUseCase } from "./updateCounterUseCase";
import type { UpdateCounterStore } from "./updateCounterUseCase";
import { increment } from "../domain/counterModel";
const incrementCounterUseCase = (store: UpdateCounterStore) => {
return updateCounterUseCase(store, increment);
};
export { incrementCounterUseCase };

decrementCounterUseCase

// useCases/decrementCounterUseCase.ts
import { updateCounterUseCase } from "./updateCounterUseCase";
import type { UpdateCounterStore } from "./updateCounterUseCase";
import { decrement } from "../domain/counterModel";
const decrementCounterUseCase = (store: UpdateCounterStore) => {
return updateCounterUseCase(store, decrement);
};
export { decrementCounterUseCase };

updateCounterUseCase

Los dos casos de uso previos dependen de updateCounterUseCase para actualizar el valor del contador. Como podemos ver los casos de uso se pueden componer.

// useCases/updateCounterUseCase.ts
import debounce from "lodash.debounce";
import type { Counter } from "../domain/counterEntity";
import type { CounterStore } from "../domain/counterStore";
type UpdateCounterStore = Pick<
CounterStore,
"counter" | "updateCounter" | "setCounter"
>;
const debouncedTask = debounce((task) => Promise.resolve(task()), 500);
const updateCounterUseCase = (
store: UpdateCounterStore,
updateBy: (counter: Counter) => Counter
) => {
const updatedCounter = store.counter
? updateBy(store.counter)
: store.counter;
// Si el contador actualizado es `undefined` o si el valor del anterior y del actualizado son ambos 0, no hagas nada
if (!updatedCounter || store.counter?.value === updatedCounter?.value) return;
store.setCounter(updatedCounter);
return debouncedTask(() => store.updateCounter(updatedCounter));
};
export { updateCounterUseCase };
export type { UpdateCounterStore };

Acá hemos implementado un debounce en la llamada a store.updateCounter para sólo actualizar el valor del contador una vez que el usuario deje de darle click a los botones aumentar o disminuir. Igualmente vemos que al llamar a store.setCounter hacemos una actualización optimista. En vez de hacer el debounce en el manipulador del evento click del botón aumentar/disminuir lo hemos hecho aquí y si bien puede parecer poco intuitivo, con esto hemos conseguido mover la lógica de la aplicación a un solo lugar, en vez de que esté repartida entre varias capas. También hemos puesto un early return para verificar que solo persistiremos el valor si hace sentido.

🤔

Aquí aún tengo dudas dado que sabemos que la data debe ser persistida a un origen remoto. Eso no nos debería importar aquí y sobre esto voy a hablar en el próximo artículo.

Controladores y Presentadores

Hasta ahora no hemos escrito nada de código específico a React: todo ha sido TypeScript plano. Esta es la primera capa en la que incluiremos React.

El rol de esta capa es encapsular los casos de uso de forma que puedan ser llamados en los componentes de UI. Si bien podríamos llamar a los casos de uso directamente allí, le estaríamos dando muchas responsabilidades como inyectar las dependencias que el caso de uso requiere y eso hace nuestro código más difícil de razonar al respecto.

Para implementar el controlador usaremos hooks y seguiremos un patrón similar a MVVM (Model-View-ViewModel). Ahondaremos sobre este patrón en otra ocasión, pero por ahora se vería algo así:

// controller/counterViewModel.ts
import React from "react";
import type { CounterStore } from "../domain/counterStore";
import { getCounterUseCase } from "../useCases/getCounterUseCase";
import { incrementCounterUseCase } from "../useCases/incrementCounterUseCase";
import { decrementCounterUseCase } from "../useCases/decrementCounterUseCase";
function useCounterViewModel(store: CounterStore) {
const getCounter = React.useCallback(
function () {
getCounterUseCase({
loadInitialCounter: store.loadInitialCounter
});
},
[store.loadInitialCounter]
);
const incrementCounter = React.useCallback(
function () {
incrementCounterUseCase({
counter: store.counter,
updateCounter: store.updateCounter,
setCounter: store.setCounter
});
},
[store.counter, store.updateCounter, store.setCounter]
);
const decrementCounter = React.useCallback(
function () {
decrementCounterUseCase({
counter: store.counter,
updateCounter: store.updateCounter,
setCounter: store.setCounter
});
},
[store.counter, store.updateCounter, store.setCounter]
);
return {
count: store.counter?.value,
isLoading: typeof store.counter === "undefined" || store.isLoading,
canDecrement: store.counter?.value === 0,
getCounter,
incrementCounter,
decrementCounter
};
}
export { useCounterViewModel };

Este hook no solo une los casos de uso a funciones específicas del framework, sino que también sirve para "traducir" nuestro estado a condiciones que hablen sobre las características de la interfaz. Con esto logramos encapsular la lógica de la vista en un solo lugar, en vez de que esté repartida a lo largo de todo nuestro JSX.

Framework, librerías y conductores

Esta es la capa más externa de nuestra arquitectura y aquí podemos dejar todo lo que sea código específico a la librería, por ejemplo:

  • Componentes de React
  • Implementación de nuestro store con alguna librería como redux, o simplemente con React
  • El servicio del contador para persistir data de forma remota
  • Un cliente HTTP para comunicarnos con la fuente de datos remota
  • Internacionalización
  • y mucho más

Comenzaremos creando el servicio del contador:

Servicio de API del contador

// data/counterAPIService.ts
import httpClient from '../../shared/httpClient'; // Esto puede ser una instancia de axios, para este caso es irrelevante
import type { Counter } from '../domain/counterEntity';
import { create } from '../domain/counterModel';
const BASE_URL = 'counter';
function getCounter(): Promise<Counter> {
return httpClient.get<number>(BASE_URL).then(res => create(res.data));
}
function updateCounter(counter: Counter): Promise<Counter> {
return httpClient.put<number>(BASE_URL, { count: counter.value }).then(res => create(res.data));
}
export { getCounter, updateCounter };

Implementación del Store

La belleza de las arquitecturas por capas es que poco nos importa la implementación de cada capa. En nuestro caso podríamos usar cualquier cosa: mobx, redux, zustand, recoil, react-query, estado local de un componente de React o lo que sea.

Por familiaridad vamos a usar redux acá, solo para demostrar que los detalles de implementación no se filtran hacia otras capas:

// data/counterActionTypes.ts
export const SET_COUNTER = "SET_COUNTER";
export const GET_COUNTER = "GET_COUNTER";
export const GET_COUNTER_SUCCESS = "GET_COUNTER_SUCCESS";
export const UPDATE_COUNTER = "UPDATE_COUNTER";
export const UPDATE_COUNTER_SUCCESS = "UPDATE_COUNTER_SUCCESS";

// data/counterActions.ts
import type { Counter } from "../domain/counterEntity";
import { getCounter, updateCounter } from "./counterService";
import * as actionTypes from "./counterActionTypes";
const setCounterAction = (counter: Counter) => (dispatch: any) =>
dispatch({ type: actionTypes.SET_COUNTER, counter });
const getCounterAction = () => (dispatch: any) => {
dispatch({ type: actionTypes.GET_COUNTER });
return getCounter().then((counter) => {
dispatch({ type: actionTypes.GET_COUNTER_SUCCESS, counter });
return counter;
});
};
const updateCounterAction = (counter: Counter) => (dispatch: any) => {
dispatch({ type: actionTypes.UPDATE_COUNTER });
return updateCounter(counter).then((counter) => {
dispatch({ type: actionTypes.UPDATE_COUNTER_SUCCESS });
return counter;
});
};
export { setCounterAction, getCounterAction, updateCounterAction };

// data/counterReducer.ts
import type { AnyAction } from "redux";
import type { CounterStore } from "../domain/counterStore";
import * as actionTypes from "./counterActionTypes";
type CounterStoreState = Omit<CounterStore, "loadInitialCounter" | "setCounter" | "updateCounter">;
const INITIAL_STATE: CounterStoreState = {
counter: undefined,
isLoading: false,
isUpdating: false
};
const counterReducer = (state: CounterStoreState = INITIAL_STATE, action: AnyAction) => {
switch (action.type) {
case actionTypes.SET_COUNTER:
return { ...state, counter: action.counter };
case actionTypes.GET_COUNTER:
return { ...state, isLoading: true };
case actionTypes.GET_COUNTER_SUCCESS:
return { ...state, isLoading: false, counter: action.counter };
case actionTypes.UPDATE_COUNTER:
return { ...state, isUpdating: true };
case actionTypes.UPDATE_COUNTER_SUCCESS:
return { ...state, isUpdating: false };
default:
return state;
}
};
export { counterReducer };
export type { CounterStoreState };

Con todo nuestro código de redux ya en lugar, podemos crear nuestra implementación de CounterStore:

// data/counterStoreImplementation.ts
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import type { AppRootState } from "../../main/data/appStoreImplementation";
import type { CounterStore } from "../domain/counterStore";
import type { Counter } from "../domain/counterEntity";
import type { CounterStoreState } from "./counterReducer";
import {
getCounterAction,
setCounterAction,
updateCounterAction
} from "./counterActions";
const counterSelector = (state: AppRootState) => state.counter;
const useCounterStoreImplementation = (): CounterStore => {
const { counter, isLoading, isUpdating } = useSelector<
AppRootState,
CounterStoreState
>(counterSelector);
const dispatch = useDispatch();
const setCounter = React.useCallback(
(counter: Counter) => setCounterAction(counter)(dispatch),
[dispatch]
);
const loadInitialCounter = React.useCallback(
() => getCounterAction()(dispatch),
[dispatch]
);
const updateCounter = React.useCallback(
(counter: Counter) => updateCounterAction(counter)(dispatch),
[dispatch]
);
return {
counter,
isLoading,
isUpdating,
setCounter,
loadInitialCounter,
updateCounter
};
};
export { useCounterStoreImplementation };

Vista

La última capa que mostraremos es la vista:

// view/AppView.tsx
import React from "react";
import Button from "../../shared/ui/Button";
import Count from "../../shared/ui/Count";
import Spinner from "../../shared/ui/Spinner";
import { useCounterViewModel } from "../controller/counterViewModel";
import { useCounterStoreImplementation } from "../data/counterStoreImplementation";
const CounterView = () => {
const store = useCounterStoreImplementation();
const {
count,
canDecrement,
isLoading,
getCounter,
incrementCounter,
decrementCounter
} = useCounterViewModel(store);
React.useEffect(() => {
getCounter();
}, [getCounter]);
return (
<div className="App">
{isLoading ? (
<Spinner />
) : (
<>
<Button onClick={decrementCounter} disabled={!canDecrement}>
dec
</Button>
<Count>{count}</Count>
<Button onClick={incrementCounter}>inc</Button>
</>
)}
</div>
);
};
export default CounterView;

No tengo mucho que agregar al respecto salvo que el trazado de la información de nuestro estado a la vista queda bien simplificado pues no es necesario saber qué implica en términos de negocio ese canDecrement.

Conclusiones

Y eso ha sido una revisión de como podemos implementar la arquitectura CLEAN en una aplicación de React. Recapitulando, los beneficios que nos brinda una arquitectura de este estilo son:

  • Hace de nuestro código más fácil de razonar, ya que cada capa tiene un rol bien definido y podemos concentrarnos en una capa sin tener que conocer los detalles de implementación del resto
  • Por lo mismo facilita la substitución de cualquier capa
  • Con barreras bien definidas nos es más fácil probar nuevas tecnologías, entre otras cosas
  • Si respetamos la regla de dependencias nos aseguramos que el negocio sea fácil de describir y probar
  • Y así mismo, cada capa puede ser probada de manera autónoma antes y durante de escribir nuestro código

Como es bien conocido en el mundo del software: no existen balas de plata. Utilizar CLEAN tiene ventajas y desventajas (una es por ejemplo la cantidad de código que hemos escrito para un ejemplo trivial). Ahondaremos sobre las desventajas de CLEAN en otro artículo. Igualmente hay muchas cosas más sobre las que me gustaría escribir en torno a esta arquitectura, pero lo dejaremos para otra ocasión.

Puedes encontrar el repositorio con el código de fuente que hemos revisado aquí.

Referencias


Y eso ha sido todo por hoy. Si te ha gustado el contenido, no te olvides de darle una compartida en Twitter y seguirme por ahí.

Gracias totales y hasta la próxima.