Developer Experience - Casos de Estudio en Startups
Developer Experience es un area desatendida por prioridades de producto, pero que puede pagar muy altos dividendos
Estuve dándole muchas vueltas a cómo escribir este artículo ya que quería enfocarlo en detalles muy específicos respecto al compiler de Typescript y cómo ayude al equipo de ingeniería en Keep con LSPs lentos y un CI que fallaba mucho, pero ya entre tanto detalle me perdí en el core de lo que quería platicar, que es el por qué importa tanto tener una buena experiencia de desarrollo y qué problemas y soluciones he visto a lo largo de mi experiencia para atender estos problemas.
¿Qué es el developer experience (DX)?
Developer Experience se enfoca en qué tan ágil y ergonómico es desarrollar sobre un codebase para que la experiencia de un desarrollador sea positiva. Todo esto puede ser muy ambiguo y es difícil de cuantificar.
Existen estándares como los DORA metrics pero son muy abiertos a interpretación y dependera de la cultura de los equipos para sacarles su mayor provecho.
Bajo mi interpretación, esto se desenvuelve entre muchos factores:
Desarrollo en local
¿Cuánto tiempo tardo en hacer un cambio y poder probarlo?
¿El hot reloading sigue funcionando en mi codebase?
¿Cuántos comandos tengo que correr en mi terminal para poder echar a andar mi proyecto?
¿Cuánto tiempo consume echar a andar el proyecto?
Pruebas automatizadas rápidas y confiables
¿Qué tan rápido corren mis pruebas en el CI?
¿El CI es estable?
¿Cuántos flaky tests hay en el código que hacen que tenga que re-intentar el CI varias veces?
Extensibilidad de código (Arquitectura)
¿Qué tan legible es el código que se escribe?
¿Qué tan fácil es agregar código nuevo sobre el ya existente?
Empujar código a producción
¿En cuánto tiempo puedo hacer un cambio y mandarlo a producción?
Casos de Estudio
Wizeline - Tests muy rápidos y buena arquitectura
En uno de los proyectos que trabajé en Wizeline, teníamos un enfoque muy grande en diseñar el sistema (una plataforma de Chatbots, 2017, con NLP en vez de LLMs) tomando en cuenta el roadmap de producto y dejando nuestro código muy flexible en las partes que sabíamos que iban a cambiar, y dejando rígidas las que no. El desarrollo en local era muy sencillo una vez que el equipo se ponía de acuerdo en cómo sería el diseño del sistema.
Pruebas automatizadas
Teníamos alrededor de 1800 tests automatizados en nuestro codebase, con un equipo de alrededor de 6 personas, en un lapso de tiempo de 6 meses. Sí, eran un número muy alto de pruebas, pero nos daban una certeza enorme de que nuestro código iba a seguir funcionando después de realizar refactors o agregar nuevas funcionalidades.
Esa confianza de desarrollo nos hizo un equipo de alto rendimiento, donde podíamos entregar features muy rápido una vez que habíamos pasado la curva inicial de desarrollar los foundations correctos para el codebase.
Todo esto puede sonar muy abstracto y poco aplicable, así que voy a dejar algunas técnicas que usabamos en ese entonces para lograrlo:
Los tests que tendrían que ir a una base de datos en local corrían con un driver de SQLite en vez de Postgres. Esto aceleraba mucho las pruebas con el riesgo de que una implementación en SQLite no corriera bien en Postgres. En ese entonces utilizabamos SQLAlchemy, específicamente el query builder, no el ORM. En el CI, los tests corrían sobre Postgres.
Los tests estaban completamente desacoplados, ningún tests dependía del test anterior, y teníamos una buena estructura para acomodarlos (Arrange/Act/Assert).
Un error muy común que he visto es que hay muchos tests que dependen de que la base de datos esté en cierto estado para correr, y que muchas veces se termina generando un acoplamiento entre las pruebas y el setup inicial. Lo que a mi más me ha funcionado es utilizar test factories para crear objetos on the fly. En node lo que me ha gustado más utilizar es fishery.
Como resultado de la inversión que hicimos en esto, todo nuestro suite de pruebas corría en 8 segundos. Eran 8 segundos para darnos cuenta si habíamos roto funcionalidad clave o no del sistema. Este feedback loop tan rápido nos ayudaba a construir momentum muy rápido
Arquitectura
En nuestro proyecto de chatbots, nuestro codebase utilizaba mucho la filosofía de Domain Driven Design, específicamente separar bien los Bounded Context, que significa las funcionalidades de un sistema que son independientes entre sí, a pesar de que compartan las mismas entidades o conceptos. Eso nos permitía que nuestros tests estuvieran aislados entre sí y que pudieramos probar cosas de manera muy independiente.
Sobre estos dominios que diseñabamos, podíamos hacer decisiones muy interesantes. Los dominios que menos cambios iban a requerir con el tiempo los hacíamos con las herramientas más sencillas posibles, nótese algo como SQLAlchemy ORM en su momento (Prisma sería su equivalente en node), casi casi como un CRUD plano, sin darle muchas vueltas a cómo ibamos a acomodar el código.
Toda esta idea de siempre hacer todo el código bien abstraido y con Don’t Repeat Yourself y todos esos buzzwords que escuchamos del “Good Design” no tienen ningún sentido. Hay dominios (areas de expertise) que no son complejos y hay que hacerlos con patrones sencillos, un controlller y un ORM, y si a caso algunos layers en medio para casos de uso mas complejos, pero no se puede aplicar una solución para todos los problemas.
¿Y qué tiene que ver esto con el Developer Experience?
Absolutamente todo. Nuestra velocidad de desarrollo era muy eficiente, y los developers que entraron a trabajar a nuestro equipo podían hacer su onboarding en un día y mandar un Pull Request en su primer día, con la confianza de que sabíamos que no iban a romper nada del sistema.
Todo esto era porque los valores de nuestro equipo eran compartidos, y sentíamos un sentido de pertenencia a un proyecto y a entregar código de calidad. En un refactor que hicimos donde dejabamos de usar un In-Memory Queue a usar Rabbit MQ, nuestro QA (Eric Barriga, saludos) nos dijo “Oigan, ¿Sí cambiaron algo? Todo sigue funcionando igual”.
Todo eso costó muchísimo, ya que es muy fácil tomar los “shortcuts” para entregar algo con menor calidad, pero al final creo que valió la pena aunque no fue un producto muy exitoso en el largo plazo, tuvimos mucho aprendizaje y creo que re-definió mucho de mi visión de cómo se ve un equipo de los que llaman “alto rendimiento”.
Caso de estudio - Keep
Mi onboarding en Keep fue bastante rápido gracias a la estructura del proyecto que se manejaba como un monorepo (como ya muchas startups en la industria actualmente). Lo grandioso de los monorepos es que puedes estructurar acciones comunes entre varios proyectos y correr “todos los tests de todos los proyectos” o correr “todos los servers” con un sólo comando.
Un problema muy grande que noté al entrar fue nuestro CI. La velocidad de nuestro CI era lenta comparado al tamaño de nuestro codebase y el cómo teníamos estructuradas las pruebas. Otro problema grande era nuestro LSP (Language Server Protocol) que para nuestros proyectos de node, el typescript-language-server se moría después de unos 10-20 autocompletes, para después tener que reiniciar el server. Todo este proceso era bastante problemático ya que provocaba una experiencia de desarrollo muy problematica.
Fun fact, todo este artículo empezó porque quería hablar sobre este tema del LSP, pero eso lo haré en otro artículo ya que estaba quedando muy extenso y difícil de seguir.
El problema del LSP tenía que ver con la cantidad de código que el server tenía que cargar para inicializar un proyecto. El código es directamente proporcional al número de dependencias que tienes en un proyecto. Cuando tú descargas un paquete de npm, dependiendo de cómo esté empaquetado el paquete, tú recibiras código JS bundleado o compilado, o podrías estar recibiendo código fuente de un paquete, o tal vez código de Typescript pero sin declarations.
Todo ese mar de dependencias no estandarizadas puede hacer que nuestros proyectos carguen mucho más código del necesario, y terminemos con un LSP bastante lento, ya que al menos en el caso de typescript, el LSP corre `tsc`, que es el compiler de Typescript, para resolver todos los tipos y dependencias.
Solución a muy alto nivel del LSP
Utilizamos el mecanismo de tracing que tiene el compilador de typescript para identificar las partes en las que el codigo se cargaba mas lento, o en las que se cargaba mucho código. El resultado que es un documento que sigue la especificación de Open Tracing, el cuál podíamos usar dentro de chrome ingresando a about://tracing.
Lo que se ve a continuación es algo llamado Flamegraph, que consiste en una descripción temporal y lineal de, en este caso, todos los archivos que se invocan para hacer la resolución de módulos de un programa. Dentro del flamegraph, se pueden observar qué archivos importan qué módulos, y ver cuánto está tardando en cargar.
El eje horizontal representa el tiempo, así que si observas que hay partes del flamegraph que se extienden mucho sobre el tiempo, es momento de observar y determinar cuál es la razón de ello.

Después de unos días, determinamos que había ciertas dependencias que no venían con declaration files, y nuestro proyecto estaba compilando esas dependencias. Al momento de removerlas, encontramos una mejora substancial en el performance de nuestro LSP. Aún no perfecta, pero permitía a los ingenieros trabajar de manera más fluida y sin interrupciones por el malfuncionamiento del LSP (básicamente, sin autocompletes ni sugerencias de tipos).
El CI fallaba un montón, ¿Qué hacemos?
Creo que no es necesario enfatizar en la importancia y criticalidad de tener un CI que funcione de manera consistente y que sea rápido, ya que de él deriva un deployment exitoso a production. Como un paso crítico de un equipo que sigue la metodología de Continuous Delivery, es sumamente importante la estabilidad de este proceso.
Bueno, nuestro problema con el CI era el siguiente:
Nuestro failure rate del CI se ubicaba en un 13% debido a varios flaky tests que se encontraban en el sistema, y también a errores random que sucedían en nuestras máquinas (utilizabamos self-hosted instances).
El tiempo P95 (peor escenarios) rondaba entre los 27 minutos en total para correr tests unitarios, integración, E2E, entre otros checks.
La gente se quejaba un montón de que no podía ni hacer deployments ni que pasar el CI en sus pull requests.
¿Cómo arreglamos este problema, teniendo tanto trabajo en producto? ¿Quién le va a dedicar tiempo a estas cosas que no parecen tener tanta relevancia?
Bueno, ahí fue donde el CTO y yo hicimos una apuesta, donde salí de mi rol de Engineering Manager en el equipo de banking, para dedicarme de tiempo completo en resolver estos problemas, ya que en un equipo de alrededor de 20 ingenieros, esto sí era algo que bloqueaba a producto en hacer releases.
Fueron muchas iteraciones para resolverlo, pero a alto nivel, los pasos que seguí en ese momento fueron:
Revisar la estructuctura de nuestros Github Workflows, para ver cómo funcionaban cada uno paso a paso, desde cómo se instalan las dependencias, y cómo se ejecuta cada uno de los pasos, revisar qué partes se podían optimizar o incluso borrar.
Revisar los specs de nuestras self-hosted instances, lo cual para nuestra mala fortuna ya eran máquinas con specs altos (mucho CPU y memoria), así que no había demasiado que configurar.
Revisar cómo se corrían las pruebas, ya que al tener un monorepo, las pruebas de varios paquetes se podían correr en uno o varios workflows de Github.
Migración a Warpbuild
Aquí empieza lo “divertido”. Un viernes por la tarde decido mandar un cambio a la inicialización de nuestros self-hosted instances. Un pull request bastante inofensivo de un par de líneas de configuración en terraform. Bueno… se cayó todo el CI cuando afortunadamente ya nadie estaba trabajando porque era algo tarde.
Tras algunos intentos el domingo para revivir las self-hosted instances, no era posible hacer que se re-conectaran al network de Github para que pudieran ya actuar como runners. Estuve varias horas ese domingo desde muy temprano, hasta que hice una apuesta algo loca pero que funcionó, y bastante bien, migrarnos fuera de los self-hosted runners.
Los self-hosted runners son instancias de cloud computing, díganse un EC2, al cuál le instalas las dependencias que tiene tu proyecto, y las configuras para que puedan funcionar como máquinas para tu CI, en vez de usar máquinas de algún vendor. No entraré mucho en detalles de cuándo usar self-hosted o no, pero puedo comentar que los trade-offs de utilizarlas es que si empiezan a fallar, te va a tocar debuggear las instancias a mano, y si se corrompe una máquina, la vas a tener que reiniciar o destruir y re-construir nuevas. En el caso de Keep, nuestras self-hosted nos daban muchos problemas y nos quitaban tiempo valioso de pruebas y deployment.
Los Self-hosted runners tienen cierto overhead de mantenimento, el cual muchas empresas están dispuestas a pagar, pero en el caso de una startup a mi muy humilde opinión, puede salir más caro en tiempo y esfuerzo.
El experimento consistió en agarrar un vendor, ya sea Github o algun otro, para correr nuestros tests ahí. La elección de ese momento fue Warpbuild, una startup de YC que proveé runners para correr tests. Sí, es lo único que hacen, pero lo hacen tan bien que pueden reducir el tiempo de ejecución del CI en un 30% por default, y estas máquinas son 50% más baratas que las instancias que proveé Github. Listo, se acabó el comercial.
La migración de Warpbuild consistió en establecer nuevos roles en AWS para autorizar a estos runners a acceder a ciertos recursos de nuestra infraestructura para correr los tests, tales como S3, ECS, Parameter Store, entre otros. Una vez que estos roles estaban definidos, los runners podían autenticarse a nuestra infraestructura, y correr los tests.
El cambio de runners fue una sola linea, se ve así:
runs-on: warp-ubuntu-latest-x64-4x
Una vez que hice el cambio, para el lunes nuestro CI ya estaba estable, y quedaba ver si era una solución para el largo plazo o sólo nos estaba arreglando el problema inmediato. Después de 2 semanas piloto, sentíamos que ya teníamos mucho más control sobre nuestro CI, así que un gran shoutout al equipo de Warpbuild que está haciendo un gran trabajo con esto!
Removiendo Flaky Tests
El siguiente paso para estabilizar el CI consistía en remover flaky tests. Haciendo re-arrangement de los tests que se corrían en cada workflow para evitar “overlap” de entidades de bases de datos creadas entre paquetes, se lograron disminuir los flaky tests en un 95%, ya que muchos de los tests que fallaban eran por tener datos de otros tests. Esto fue una solución muy hacky ya que no arreglaba los problemas de raíz de cómo algunos tests estaban escritos, pero era un buen 80/20 para seguir poniendo esfuerzo en otras areas.
¿Y el Developer Experience?
Pues, después de estos improvements, y de agregar un último experimento para cachear los node modules usando un hash de nuestro lockfile de dependencias, pudimos:
Respecto al LSP, la gente ya puede trabajar sin tener el LSP muerto (sigue algo lento en algunas partes del código, pero hicimos un buen 80/20 ahi también)
Hacer que el P95 de nuestro CI fuera de 10 minutos en vez de 27 minutos.
Reducir el failure rate de nuestro CI de 13% a 1.2%!!
Reducir el P95 de nuestro pipeline de deployments de 35 minutos a 10 minutos también
Con todas estas mejoras, nuestro Lead Time (hablando de Dora Metrics) bajó muchísimo en varios equipos. No tengo el dato exacto, pero sí estamos hablando que había equipos que de tardar varios días en mergear un PR, ya lo hacían en cuestion de horas o un día.
Conclusiones
Todo esto a pesar de que fui el encargado de hacer que esto sucediera, no lo hice solo, recibí mucha ayuda de miembros clave del equipo como Victor Ligas y Helson Taveras, que hicieron que esto fuera posible, y que hicieran que esta contribución fuera de las que más impacto he logrado en un area que me gusta un montón, el DX.
Les agradezco un montón a los que llegaron hasta aquí, estuvo algo extenso el artículo ahora. Me gustaría conocer personas interesadas en el tema así que siempre estamos a un mensaje de distancia :)