TypeScript todo en uno: El Monorepo con sus agravios y ganancias
En este artículo, no voy a comparar monorepo y polyrepo ya que se trata de filosofía. En su lugar, me centraré en la experiencia de construir y evolucionar y asumiré que estás familiarizado con el ecosistema JS/TS.
Introducción
Siempre tuve el sueño de un monorepo.
Vi el enfoque de monorepo mientras trabajaba para Airbnb, pero era solo para el frontend. Con un profundo amor por el ecosistema de JavaScript y la "feliz" experiencia de desarrollo de TypeScript, empecé a alinear el código de frontend y backend en el mismo idioma hace ~tres años. Fue genial (para contratar) pero no tan genial para desarrollar ya que nuestros proyectos aún estaban dispersos en múltiples repositorios.
Como dice el dicho, “la mejor manera de refactorizar un proyecto es comenzar uno nuevo”. Así que cuando estaba comenzando mi startup hace un año, decidí utilizar una estrategia total de monorepo: poner proyectos de frontend y backend, incluso esquemas de base de datos, en un solo repositorio.
En este artículo, no compararé monorepo y polyrepo ya que se trata de filosofía. En su lugar, me centraré en la experiencia de construir y evolucionar y asumiré que estás familiarizado con el ecosistema JS/TS.
El resultado final está disponible en GitHub.
¿Por qué TypeScript?
Hablando francamente, soy un fanático de JavaScript y TypeScript. Amo la compatibilidad de su flexibilidad y rigurosidad: puedes retroceder a unknown
o any
(aunque prohibimos cualquier forma de any
en nuestro código), o usar un conjunto de reglas de lint superpuntuales para alinear el estilo de código en todo el equipo.
Cuando hablábamos del concepto de “fullstack” antes, generalmente imaginamos al menos Dos ecosistemas y lenguajes de programación: uno para frontend y uno para backend.
Un día, de repente me di cuenta que podría ser más simple: Node.js es suficientemente rápido (créeme, en la mayoría de los casos, la calidad del código es más importante que la velocidad de ejecución), TypeScript es lo suficientemente maduro (funciona bien en grandes proyectos de frontend), y el concepto de monorepo ha sido practicado por muchos equipos famosos (React, Babel, etc.) - entonces, ¿por qué no combinamos todo el código, desde el frontend hasta el backend? Esto puede hacer que los ingenieros hagan los trabajos sin cambios de contexto en un solo repositorio e implementen una característica completa en (casi) un solo idioma.
Elección del administrador de paquetes
Como desarrollador, y como es habitual, no podía esperar a empezar a codificar. Pero esta vez, las cosas fueron diferentes.
La elección del administrador de paquetes es crucial para la experiencia de desarrollo en un monorepo.
El dolor de la inercia
Era julio de 2021. Comencé con [email protected]
ya que lo he estado usando durante mucho tiempo. Yarn era rápido, pero pronto me encontré con varios problemas con Yarn Workspaces. P.ej., no izando las dependencias correctamente, y toneladas de problemas están etiquetados con “fixed in modern”, que me redirige a la v2 (berry).
"Está bien, estoy actualizando ahora". Dejé de luchar con la v1 y empecé a migrar. Pero la larga guía de migración de berry me asustó, y me di por vencido después de varios intentos fallidos.
Simplemente funciona
Por lo tanto, comenzó la investigación sobre los administradores de paquetes. Pnpm
me absorbrió después de una prueba: rápido como yarn, soporte nativo de monorepo, comandos similares a npm
, enlaces duros, etc. Lo más importante es que simplemente funciona. Como un desarrollador que quiere empezar con un producto pero NO desarrollar un administrador de paquetes, solo quería agregar algunas dependencias y comenzar el proyecto sin saber cómo funciona un administrador de paquetes o cualquier otros conceptos elegantes.
Basándonos en la misma idea, elegimos a un viejo amigo lerna
para ejecutar comandos a través de los paquetes y publicar paquetes de espacio de trabajo.
Definición de los alcances del paquete
Es difícil determinar claramente el alcance final de cada paquete al principio. Solo comienza con tu mejor intento de acuerdo con el estado actual, y recuerda que puedes refactorizar siempre durante el desarrollo.
Nuestra estructura inicial incluye cuatro paquetes:
core
: el servicio monolito de backend.phrases
: recursos de frases claves de i18n.schemas
: las bases de datos y los esquemas de TypeScript compartidos.ui
: una SPA web que interactúa concore
.
Pila de tecnología para fullstack
Como estamos adoptando el ecosistema de JavaScript y usando TypeScript como nuestro principal lenguaje de programación, muchas elecciones son directas (basadas en mi preferencia 😊):
koajs
para el servicio backend (core): tuve una difícil experiencia usandoasync/await
enexpress
, por lo que decidí usar algo con soporte nativo.i18next/react-i18next
para i18n (frases/ui): me gusta su simplicidad de API y el buen soporte de TypeScript.react
para SPA (ui): Solo preferencia personal.
¿Qué pasa con los esquemas?
Algo todavía falta aquí: sistema de base de datos y esquemas <-> Mapeo de la definición de TypeScript.
General v.s. Opinión
En ese momento, probé dos enfoques populares:
- Usar ORM con muchos decoradores.
- Usar un generador de consultas como Knex.js.
Pero ambos producen una sensación extraña durante el desarrollo anterior:
- Para ORM: No soy un fanático de los decoradores, y otra capa abstracta de la base de datos causa más esfuerzo de aprendizaje e incertidumbre para el equipo.
- Para el generador de consultas: Es como escribir SQL con algunas restricciones (de una buena manera), pero no es SQL real. Por lo tanto, necesitamos usar
.raw()
para consultas en bruto en muchos escenarios.
Luego vi este artículo: “Deja de usar Knex.js: Usar el constructor de consultas SQL es un antipatrón”. El título parece agresivo, pero el contenido es genial. Me recuerda fuertemente que “SQL es un lenguaje de programación”, y me di cuenta que podría escribir SQL directamente (¡como CSS, cómo pude perderme esto!) para aprovechar el lenguaje nativo y las funciones de la base de datos en lugar de agregar otra capa y reducir el poder.
En conclusión, decidí seguir con Postgres y Slonik (un cliente de Postgres de código abierto), como indica el artículo:
…el beneficio de permitir al usuario elegir entre los diferentes dialectos de la base de datos es marginal y el tiempo extra de desarrollar para varias bases de datos a la vez es significativo.
SQL <-> TypeScript
Otra ventaja de escribir SQL es que podemos usarlo fácilmente como única fuente de verdad de las definiciones de TypeScript. Escribi un generador de código para transpilar esquemas SQL a código de TypeScript que utilizaremos en nuestro backend, y el resultado no se ve mal:
Incluso podemos conectar jsonb
con un tipo de TypeScript y procesar la validación de tipo en el servicio de backend si es necesario.
Resultado
La estructura de dependencia final se ve así:
Puedes notarlo, es un diagrama unidireccional, que nos ayudó mucho a mantener una arquitectura clara y la capacidad de expansión mientras el proyecto crece. Además, el código está (básicamente) todo en TypeScript.
Experiencia de desarrollo
Compartiendo paquetes y configuraciones
Dependencias internas
Pnpm
y lerna
están haciendo un trabajo impresionante en las dependencias internas del espacio de trabajo. Usamos el siguiente comando en la raíz del proyecto para agregar paquetes hermanos:
Esto agregará @logto/schemas
como una dependencia a @logto/core
. Mientras mantiene la versión semántica en package.json
de sus dependencias internas, pnpm
también puede enlazarlas correctamente en pnpm-lock.yaml
. El resultado se verá así:
Compartir configuraciones
Tratamos cada paquete en monorepo como “independiente”. Por lo tanto, podemos usar el enfoque estándar para compartir configuraciones, que abarca tsconfig
, eslintConfig
, prettier
, stlyelint
, y jest-config
. Consulta este proyecto para ver un ejemplo.
Código, lint y commit
Utilizo VSCode para el desarrollo diario, y en resumen, nada es diferente cuando el proyecto está configurado correctamente:
- ESLint y Stylelint funcionan normalmente.
- Si estás utilizando el plugin ESLint de VSCode, agrega las siguientes configuraciones de VSCode para que respete la configuración de ESLint por paquete (reemplaza el valor de
pattern
con el tuyo):
- Si estás utilizando el plugin ESLint de VSCode, agrega las siguientes configuraciones de VSCode para que respete la configuración de ESLint por paquete (reemplaza el valor de
- husky, commitlint y lint-staged funcionan como se espera.
Compilador y proxy
Estamos utilizando diferentes compiladores para frontend y backend: parceljs
para UI (React) y tsc
para todos los demás paquetes de TypeScript puros. Recomiendo encarecidamente que pruebes parceljs
si aún no lo has hecho. Es un compilador de “cero configuración” que maneja elegantemente diferentes tipos de archivos.
Parcel aloja su propio servidor de desarrollo de frontend, y la salida de producción son solo archivos estáticos. Como nos gustaría montar APIs y SPA bajo el mismo origen para evitar problemas de CORS, la estrategia abajo funciona.
- En el entorno de desarrollo, utiliza un simple proxy HTTP para redireccionar el tráfico al servidor de desarrollo de Parcel.
- En producción, sirve archivos estáticos directamente.
Puedes encontrar la implementación de la función de middleware de frontend aquí.
Modo Watch
Tenemos un script dev
en package.json
para cada paquete que detecta los cambios de archivos y recompila cuando es necesario. Gracias a lerna
, las cosas se vuelven fáciles usando lerna exec
para ejecutar scripts de paquetes en paralelo. El script raíz se verá así:
Resumen
Idealmente, solo dos pasos para que un nuevo ingeniero/colaborador comience:
- Clona el repositorio
pnpm i && pnpm dev
Notas finales
Nuestro equipo ha estado desarrollando bajo este enfoque durante un año, y estamos bastante contentos con él. Visita nuestro repositorio GitHub para ver la última forma del proyecto. Para resumir:
Agravios
- Necesidad de familiarizarse con el ecosistema JS/TS
- Necesidad de elegir el administrador de paquetes adecuado
- Requiere una configuración adicional única
Ganancias
- Desarrollar y mantener todo el proyecto en un solo repositorio
- Simplificación de los requerimientos de habilidades de codificación
- Estilos de código compartidos, esquemas, frases y utilidades
- Mejora de la eficiencia de la comunicación
- No más preguntas como: ¿Cuál es la definición de la API?
- Todos los ingenieros hablan en el mismo lenguaje de programación
- CI/CD con facilidad
- Usa la misma cadena de herramientas para construir, probar y publicar
Este artículo deja varios temas sin cubrir: Configuración del repositorio desde cero, agregar un nuevo paquete, aprovechar las acciones de GitHub para CI/CD, etc. Sería demasiado largo para este artículo si expando cada uno de ellos. No dudes en comentar y hacerme saber qué tema te gustaría ver en el futuro.