Un meta-framework por devs, para devs
En 2023, 2 millones de personas pagaron con Fintoc.
Todos esos pagos se hicieron a través del widget, la interfaz por la que puedes conectarte a Fintoc y hacer un pago. Para que te hagas una idea, este widget nació a inicios del 2021, hace más de 3 años.
Este widget era un MVP para el producto de pagos, pero terminó siendo la solución que nos permitió funcionar por años.
Partió como una aplicación de frontend estándar de un par de líneas y una que otra clase de Tailwind. Si bien tuvo muchas mejoras visuales y de experiencia para los usuarios que pagan con Fintoc, durante mucho tiempo no implementamos mejoras en su arquitectura.
Somos una startup. El negocio va creciendo y con eso el producto se tiene que ir adaptando: un cambio chico por acá, una nueva feature por allá, un if
para implementar un caso especial de manera rápida y otro if
porque en un país ese flujo en particular funciona distinto que en los demás. Y todo eso se va acumulando.
Lo que nos pasó con el widget es parecido a lo que pasa cuando ordenas tu clóset. Dejas las poleras dobladas de cierta forma, las agrupas por colores y dejas a mano lo que más usas, pero con el tiempo ese orden se va perdiendo. Además, lo ordenaste en verano y el clima está un poco impredecible, así que cuando llega el momento de comprar ropa abrigada de invierno (spoiler: mucho antes de lo que crees) el clóset está tan desordenado que no tienes dónde poner las cosas nuevas.
Eso, sumado a varios otros desafíos (como bundling o manejo de rutas) nos hicieron tomar la difícil decisión de reescribir la aplicación completa. Y no solo la reescribimos, sino que implementamos un meta-framework específicamente para programar nuestro widget y así agilizar el desarrollo de nuestro equipo completo.
Te voy a contar la historia de redención y aprendizaje que vivimos como equipo cuando llegó la hora de pegarse un Marie Kondo y le dijimos basta al clóset desordenado que teníamos en el código de nuestro widget.
El widget de Fintoc
Originalmente, nuestro widget fue escrito como una SPA estándar. Tenía sentido en el momento, porque manejaba un flujo de usuario muy simple y era relativamente pequeño.
Pero a medida que fue pasando el tiempo, el flujo se fue complejizando más y más.
Aún simplificando los estados y transiciones posibles a menos del 10% de los que hay, se nota que el flujo se complicó muchísimo. Incluso es difícil de entender visualmente.
La forma en que estábamos desarrollando el widget no se amoldó a la complejidad del problema ni a la cantidad de desarrolladores cambiándolo. Esto derivó en muchas dificultades para agregar funcionalidades nuevas y modificar código existente. Eso, sumado a otros problemas relativamente fundacionales del proyecto —como tiempos de descarga inicial demasiado altos, falta de tipos estáticos, complejidad de testear, entre otros— nos hicieron tomar la difícil decisión de rediseñar el proyecto.
Nuestro propio meta-framework
Un gran aprendizaje de nuestra versión inicial del widget fue que los devs que desarrollan sobre el proyecto no siempre son expertos en frontend.
Nuestra solución necesitaba usar algunos trucos para resolver nuestros problemas más complejos (disminuir el tiempo de carga, tener el código muy fuertemente tipado, etc), pero las ✨ magias ✨ para lograrlo tenían que ser absolutamente invisibles y “automáticas” para que devs sin tanto conocimiento específico no pudieran cometer errores como introducir tipos loose (por ejemplo usar string
en un escenario en que el tipo debería admitir solamente 'primary' | 'secondary'
) o aumentar los tiempos de carga sin darse cuenta. A medida que fui construyendo herramientas para los demás devs, el repositorio se empezó a parecer más a un framework que a un proyecto regular.
También era importante que nuestro código fuera extremadamente testeable, admitiera cambios sin necesidad de hacer refactors y fuera simple de entender en algún alto nivel.
La solución a estos problemas fue basarnos en una máquina de estados.
Máquinas de estados (y el modelo del actor)
El flujo de un pago puede ser representado perfectamente por un diagrama de flujo. De hecho, usamos estos diagramas para entender el flujo que siguen nuestros usuarios. ¿Por qué no, entonces, programar usando un diagrama de flujo?
En palabras simples, una máquina de estados es una forma de expresar un diagrama de flujo, usando estados, contexto y transiciones. Esta máquina funciona como un blueprint que después un actor ejecutará.
Un actor es un proceso “vivo” que puede emitir, recibir y reaccionar a eventos. Cada actor tiene un estado actual y un contexto y puede modificar ambos dependiendo de los eventos que recibe. Un actor usa la máquina de estados para decidir de qué estado a qué estado transicionar, dependiendo de los eventos que recibe.
En Fintoc, cada combinación de país/producto tiene su propia máquina de estados y, al abrir el widget, se elige una máquina de estados a ejecutar y se inicia un actor para llevar a cabo el flujo del pago, ejecutando la máquina de estados elegida. Para modelar nuestras máquinas de estado usamos xstate
, una popular librería para JavaScript.
Esta estructura nos trajo varios beneficios:
- Toda la lógica del widget se encuentra en la máquina de estados. La capa de presentación (o las vistas) solo muestra datos del contexto del actor y emite eventos (user input). El actor decide qué hacer con eso, no los componentes. Eso significa que el flujo de un pago se puede ejecutar sin renderear un solo pixel (los eventos en ese caso podrían mandarse al actor usando un mecanismo distinto a la UI, por ejemplo emitiendo los eventos de manera programática). En efecto, la lógica del actor está 100% desacoplada de la UI.
- Como la UI está separada de la lógica, testear la lógica resulta muy simple. Y como la lógica está autocontenida, se puede mockear muy fácilmente para testear el comportamiento aislado de la UI, y se pueden levantar ambas cosas para hacer tests de integración.
- Como el actor es el que toma todas las decisiones, se puede usar la estructura de la máquina de estados para hacer ✨ magias ✨ como manejar las vistas automáticamente, descargar assets que se usarán en vistas futuras y otras cosas similares.
En la foto, el Secondary Actor
es un segundo actor que no ejecuta el flujo principal del diagrama, sino que se encarga de las tareas asíncronas (como llamadas al backend). Es un actor cuya máquina de estados modela una promesa. Acá se puede leer un poco más sobre qué problema resuelven, pero para efectos de este post no son tan relevantes (aunque son cruciales para el funcionamiento del widget).
Este diseño es la base de nuestro meta-framework y nos permitió implementar otras funcionalidades que mejoran la experiencia de desarrollar y mantienen lo mejor posible la experiencia a la hora de realizar un pago en Fintoc.
Dentro de las funcionalidades que salieron de este diseño están:
1. Automagic Routing
No quería que los devs tuvieran que manejar el router a mano (usar router.push
directamente en la vista además de emitir eventos al actor). Es una cosa más que puede fallar y que no es necesaria, ya que las vistas en nuestro widget están siempre asociadas a estados de la máquina de estados. Así que diseñé algunas herramientas que automatizaron esta tarea.
La vista se “declara” en las rutas simplemente con un nombre de la siguiente manera (usaremos bank-selection
para este ejemplo):
// src/cl/payments/routes.ts
import type { RouteRecordSingleViewWithChildren } from 'vue-router';
import { generatePrefetchedRoute } from '@/shared/routes';
import { renderViewFactory } from '@/shared/views';
const NAMESPACE = 'cl/payments';
export const routes = {
path: `/${NAMESPACE}`,
component: () => import('./layout/Layout.vue'),
children: [
...,
generatePrefetchedRoute({ name: 'bank-selection', component: () => import('./modules/bank-selection/BankSelection.vue') }),
...,
],
} as const satisfies RouteRecordSingleViewWithChildren;
export type Route = typeof routes['children'][number]['name'];
export const renderView = renderViewFactory<typeof NAMESPACE, Route>(NAMESPACE);
Hay que considerar que este router ya está armado. El dev agregando una feature solamente tiene que agregar la linea dentro dechildren
apuntando a su vista, usandogeneratePrefetchedRoute
.
Después, se declara el estado al que corresponden usando la herramienta renderView
:
// src/cl/payments/state-machine/index.ts
import { createMachine } from 'xstate';
import { renderView } from '@/cl/payments/routes';
export const machine = createMachine({
states: {
'bank-selection': {
...renderView('bank-selection'),
// here goes the rest of the state definition
},
},
});
Ahora, cuando el actor entre en el estado bank-selection
, automágicamente el router rendereará BankSelection.vue
. Si dos estados consecutivos renderean la misma vista, esa vista no se montará dos veces. El desarrollador no tiene que preocuparse por cómo esto ocurre, simplemente ocurre.
Por otro lado, y aunque no se pueda apreciar en el snippet, el parámetro que recibe renderView
está automáticamente tipado con todos los name
s de las rutas posibles, así que hay autocompletion en el IDE (y TypeScript se encargará de que no se puedan renderear vistas “que no existan”).
2. Pre-fetching a nivel de vista
Uno de los requisitos más grandes del proyecto fue que el widget se abriera rapidísimo. Para esto, necesitaba que el peso del bundle inicial disminuyera. Además, quería que los desarrolladores nunca tuvieran que preocuparse de estar aumentando el tiempo de carga inicial del widget, eso se debería manejar automáticamente.
Lo que hicimos, entonces, fue separar cada vista en su propio chunk. De esta manera, las vistas (y los assets que utilizan) no le agregan peso a la descarga inicial del widget y agregar assets a cl/payments
no hace que mx/payments
se descargue más lento.
Pero si estas cosas no se descargan al inicio, ¿cuándo se descargan?
El widget automágicamente sabe qué vistas pueden necesitarse inmediatamente después de la actual y las descarga antes de necesitarlas. El dev no tiene que preocuparse de por cómo esto ocurre. Ni siquiera hay un snippet de código que les pueda mostrar, es 100% automático.
En el GIF de arriba, las vistas en verde representan las vistas que se han descargado, las flechas punteadas verdes indican las vistas que se están descargando anticipadamente y la flecha verde grande que dice current state
representa el estado actual del actor ejecutando la máquina de estados. Se puede ver cómo, a medida que el actor avanza, se descargan anticipadamente solo las posibles vistas siguientes.
3. Runtime dinámico, tipado estático
Tenemos más de una posible máquina de estados que puede ser usada por el actor principal (por ejemplo cl/payments
y mx/payments
) y la máquina se elige dinámicamente en runtime. El problema de esto es que hace difícil tipar fuertemente las cosas que dependen del actor, porque el actor podría estar ejecutando la lógica de la máquina de estados de Chile o México y no hay forma (en type check) de saber.
Pero hay un hack. Como nuestro meta-framework maneja las vistas, está asegurado que las vistas de Chile siempre estarán ejecutando un actor para la máquina de estados de Chile y lo mismo para México. Tenemos, entonces, un hook que retorna el estado y el contexto tipado para un país específico. El hook se usa idéntico en Chile y en México, simplemente devolverán tipos distintos. Un ejemplo sería el siguiente:
<!-- src/cl/payments/modules/bank-selection/BankSelection.vue -->
<script setup lang="ts">
const { state, context, send } = useCurrentActor();
const login = (options: { username: string, password: string }) => {
send('login-attempt', options);
}
</script>
<template>
State {{ state }}, Context {{ context }}
<BankSelectionForm @submit="login" />
</template>
La gracia es que send
del useCurrentActor
usado en cl/payments
solamente puede recibir los eventos de Chile (el compilador de TS avisa si se le intenta pasar otra cosa), state
solamente puede corresponder a los estados de la máquina de estados de Chile y context
está tipado como el contexto de la máquina de estados de Chile. Y al usar useCurrentActor
en mx/payments
, send
, state
y context
están tipados específicamente para esa máquina de estados. Sin que el dev programando tenga que saber siquiera que existen otros países.
Esto es solo el comienzo
Cada una de estas herramientas y soluciones en el widget tuvieron varios desafíos de implementación que no abordé en este post, y tecnología interesante en la que no alcancé a profundizar.
Esta es la primera parte de una serie de posts en los que voy a contar cómo implementamos algunas de estas ✨ magias ✨ más en profundidad. Por ejemplo, cómo hicimos para tipar nuestras rutas y useCurrentActor
, cómo implementamos el prefetching por debajo y más.
Si te interesan las herramientas para desarrolladores y cómo simplificar el trabajo de otros devs a través de software, probablemente te gustaría trabajar en Fintoc. Te invito a postular a alguna de las vacantes que tenemos abiertas.