Mientras más crece el código de una aplicación, más nos cuesta encontrar el archivo que queremos y es más difícil debuggear. Llega un punto en el que el código ha crecido tanto y maneja tantas funcionalidades que los equipos ya no pueden conocerlo entero y mantenerlo, en especial los desarrolladores nuevos. La curva de aprendizaje se empieza a complicar.
El primer commit de Fintual fue hace 5 años. Hoy vamos en más de 33.500 commits y contamos con más de 435.000 líneas de código. Estamos en un punto de inflexión entre la posibilidad - o no - de tener programadores que conozcan prácticamente todo el código de Fintual. Y como estamos creciendo mucho en desarrolladores y en código, tuvimos que tomar una decisión de cómo manejarlo.
En Fintual, toda nuestra aplicación corre en un monolito sobre Ruby on Rails (RoR). Este framework web, basado en el clásico Model View Controller (MVC), tiene la particularidad de que asume configuraciones y comportamientos estándar y potencia la reutilización de código. En palabras simples, hace muchas cosas como “magia” de la que no te tienes que preocupar.
Si conoces cómo funciona RoR, la magia es de gran ayuda. Permite desarrollar más rápido y con menos código. Esto, sumado a una estructura ordenada MVC, facilita un orden en los archivos y clases, en donde no es difícil encontrar dónde se hace cada acción, modificar y extender funcionalidades.
Pero cuando empiezas a crecer y el código se multiplica se complica la situación. Empezamos a generar namespaces para ordenar el código y a separar las responsabilidades de clases en otros tipos de componentes más allá de un MVC. En el monolito tenemos 19 tipos de componentes además de models-views-controllers.
Cómo manejar el crecimiento en código
La primera opción es separar al equipo en especializaciones de desarrollo y que cada equipo maneje su código. Es decir, subdividir en los clásicos teams de backend, frontend web y frontend mobile. Pero en Fintual somos todos Full Stack y no hay mucho interés en especializarse así por ahora. En realidad es bacán poder desarrollar una funcionalidad de manera completa. Da más sentido de pertenencia y el impacto percibido es mayor.
Otra opción es dividir el monolito en distintos microservicios, es decir, distintas aplicaciones modulares, donde cada una se ocupa de una tarea y estas se comunican entre sí para construir un producto final. Pero en Fintual no somos muy fanáticos de los microservicios y un approach monolítico nos ha funcionado mejor.
¿Qué hicimos entonces? Empezamos a modularizar la aplicación en distintos engines y gemas de RoR.
Engines y gems
Un engine es una especie de mini-aplicación web con prácticamente toda la API y estructura que ofrece Ruby on Rails. Es decir, puedes manejar controladores, modelos, vistas, rutas y migraciones y bases de datos tal como lo harías con una aplicación RoR normal.
Pero ojo. No es un microservicio, ya que lo usamos para extender nuestra main-app. Esto significa que el contenido del engine corre en el mismo servidor, dentro del mismo proceso y contexto y usa la misma base de datos que la main-app, añadiendo sus migraciones y clases. El ejemplo más conocido es el engine de autenticación Devise.
Por otro lado están las gemas. A diferencia de un engine, no necesitan el motor de Rails, sino que son plain Ruby. En otras palabras, no cuentan con un modelo MVC ni manejan bases de datos. Es útil cuando, por ejemplo, queremos construir un Toolkit de utilidades o hacer un wrapper de una conexión a un servicio externo.
La gracia de modularizar la aplicación en engines y gems está en que podemos aislar funcionalidades o comportamientos en “mini-proyectos”.
Por ejemplo, cuando alguien quiera hacer un cambio en la funcionalidad de Fintual del manejo de los fondos mutuos, basta con que vaya al engine de Accounting. Será mucho más fácil encontrar los archivos que debe revisar y modificar. Y más importante, cuando entra un dev nuevo puede comenzar por algunas funcionalidades - engines - en vez de volverse loco mirando una main-app gigante.
Cómo empezar con Engines y Gems
Ahora que conocemos estas alternativas, ¿cómo determinamos si conviene extraer un feature de la main-app a un engine o gem? Lo que hacemos en Fintual es evaluar si vale la pena pensar cada nuevo feature como una extensión de la main-app. En otras palabras, hay que preguntarse si es posible desenchufar esta funcionalidad de la main-app sin romperla.
Un ejemplo es nuestro feature de referidos, parecido al programa de afiliados de Shopify, Amazon y Uber. El programa de referidos permite a cada usuario recomendar Fintual a sus conocidos, y a cambio ganan un 1% del saldo promedio que mantienen sus referidos en su primer año en Fintual. Referidos es una funcionalidad completa que se podría encender y apagar sin afectar el core.
Y bueno, ¿cómo determinamos si hacerlo en una gema o un engine? Nos preguntamos si el feature que quiero desarrollar debe almacenar datos y tiene un comportamiento web. Si la respuesta es sí, entonces conviene desarrollar un engine. Si la respuesta es no y nuestra feature es lógica pura, basta con desarrollar una gema.
Continuando el ejemplo, el feature de referidos sería un engine, ya que tiene sus propios modelos de datos, controladores y vistas.
A medida que creamos nuevos engines nos dimos cuenta que podíamos abstraer gran parte del proceso, por lo que decidimos crear nuestro propio generador de engines. Si eres un desarrollador de Rails, te invitamos a usar y contribuir al desarrollo de esta herramienta mediante el repositorio que liberamos para el público general.
Un tema importante a tener en cuenta es el manejo de dependencias. En una gema es más simple, porque no suele depender de clases u objetos específicos de nuestra main-app. El lenguaje que habla es el de Ruby, recibiendo ciertos objetos estándar (un string o hash por ejemplo) y ejecutando cierta acción.
Pero un engine, dado que maneja controladores, rutas, vistas y modelos tal como lo hace nuestra main-app, puede que requiera interactuar con objetos de la main-app directamente. Por ejemplo, el engine Devise utiliza el modelo User
de nuestra main-app para el manejo de los usuarios y lo extiende. ¿Cómo manejamos esta dependencia de manera correcta?
Una forma es definir las dependencias desde la main-app en el módulo principal del engine. Por ejemplo, el engine de referidos depende de los modelos de User
y UserDeposit
de la main-app y se construye así:
require "referrals/engine"
module Referrals
extend self
MODULE_DEPENDENCIES = %i{
user_deposit_model
user_model
}
mattr_accessor *MODULE_DEPENDENCIES
def configure
yield self
require "referrals"
end
class << self
MODULE_DEPENDENCIES.each do |symbol|
define_method(symbol) do
class_variable_get(:"@@#{symbol}").constantize
end
end
end
end
De esta forma, cuando en la main-app inicializamos el engine de referidos, debemos setear estas configuraciones. Acá le decimos qué modelo de la main-app debe usar el engine.
Referrals.configure do |config|
config.user_deposit_model = "UserDeposit"
config.user_model = "User"
end
Luego podemos usar esta configuración dentro del engine. Por ejemplo, acá queremos obtener quién refirió al usuario actual, a partir de una búsqueda en el modelo User
. Pero como no conocemos el modelo User
directamente, usamos el que nos proporciona la configuración Referrals.user_model
.
def referrer_from_code(referrer_code)
Referrals.user_model.find_by(referrer_code: referrer_code)
end
Además, como un usuario puede referir, queremos que el modelo User
tenga una relación de has_many
con sus referidos. Podríamos ir al modelo User
de la main-app y agregar la relación directamente, pero si desconectamos el engine, se rompería.
Para manejar esta dependencia correctamente, lo que hacemos es añadir dinámicamente funcionalidades a la clase User
.
Referrals.user_model.class_eval do
has_many :referred_users
end
Por último, aislamos la ejecución de código condicionado a la disponibilidad de un engine, para no romper nuestra main-app en caso de que una extensión no esté cargada. Una buena forma de hacerlo es crear un Util
que verifique la presencia de un engine.
class EnginesUtil
def self.with_available_engine(engine_name, &block)
if available_engine?(engine_name)
block.call
end
end
def self.available_engine?(engine_name)
Object.const_defined?("::#{engine_name.to_s.camelize}")
end
end
Así, si por ejemplo en la vista del header, que está en la main-app, queremos agregar un botón a Referidos, tenemos que chequear que el engine esté presente y lo hacemos así:
if EnginesUtil.available_engine?(:referrals)
link_to "Referidos", app_referrals_path
end
Con este approach hemos logrado hasta ahora extraer funcionalidades de la main-app a 23 engines y 3 gems. Si bien, aún nos queda bastante por modularizar, ya se nota mayor orden y desacoplamiento del código. Hoy es más fácil encontrar el archivo que tenemos que modificar cuando queremos resolver un bug o entender el contexto de funcionamiento cuando queremos agregar un nuevo feature.
Incluso para ordenarnos en lo que nos queda por modularizar, también recurrimos a un engine. El deprecated_main
, al que movimos todas los componentes pendientes de extraer.
Pero lo más bacán es que cuando empezamos los proyectos recientes una de las principales discusiones es si se debe desarrollar el feature solicitado dentro de un engine o no. Así que no solo estamos ordenando el código pasado, sino también el que desarrollamos en el presente. Ya existe una mentalidad de modularización en los desarrolladores de Fintual.
Si te gustó este artículo y tienes un comentario o algo para complementarlo, escríbenos a cartas@fintual.com. Todas las semanas publicamos las cartas destacadas de nuestros lectores.