El error más común en rails: encapsulación y principio de responsabilidad única

Echemos un vistazo a este código de ejemplo

Esto es un ejemplo de código que nos hemos encontrado muchas demasiadas veces en diferentes proyectos hechos con rails.

En este Post vamos a intentar explicar por qué nos parece muy mala idea hacer este tipo de cosas. Y para ello, antes de meternos en harina, necesitamos entender qué hace este código:

Tenemos dos clases, Foo y Bar, que heredan de ApplicationRecord. Cuando se salva una instancia de Foo, Bar tiene que actualizar una de sus columnas con un valor derivado de Foo. Por ejemplo, incrementar un contador, almacenar el mismo valor a modo de caché… Aquí lo relevante no es la operación específica que se haga, sino que lo que tenemos entre manos es un problema de consistencia de datos así que de momento, asumamos que se guarda el mismo valor tal y como dice el ejemplo.

¿Qué problemas tiene este código? Principalmente dos: estamos rompiendo la encapsulación de Bar, y estamos acoplando las clases Foo y Bar al violar el principio de responsabilidad única de Foo.

El problema con la encapsulación: que puedas no significa que debas

Cada vez que utilizamos el método where de ActiveRecord estamos accediendo a información del estado interno de esa clase. Más concretamente al nombre de las columnas donde se persiste la información.

Por esto mismo deberíamos tratar el nombre de dichos campos como información privada; como un detalle de implementación que no debería exponerse nunca hacia fuera.

Sin embargo es incontable el número de casos que nos hemos encontrado en el que se llama al método where bien desde controladores, bien desde otros modelos. Curiosamente parece haber algo que nos frena a los desarrolladores a la hora de utilizarlo en las vistas (todavía queda algo de esperanza).

Es cierto que el método where es público. Ignoro la razón de esto y seguramente pudiera ser una discusión fascinante… a la que no podemos dedicar el tiempo suficiente para este post. Además, el hecho de que un método sea público no quiere decir que podamos usarlo donde queramos, de la misma forma que tener un coche que pueda circular a 300 km/h no quiere decir que debamos hacerlo. Es responsabilidad del desarrollador hacer un buen uso de las herramientas de las que dispone y no al revés.

Además la solución a este problema en general es bien sencillo: crea un scope. Además de respetar la encapsulación mejorarás la legibilidad y separarás la definición (el qué) de la implementación (el cómo), de forma que cuando cambies el nombre de una columna, podrás cambiar la implementación interna sin cambiar su interfaz.

El problema de la responsabilidad única

El primer (y posiblemente más conocido) principio SOLID dice que una clase sólo debería cambiar por un motivo. Reconozco que muchas veces me cuesta discernir cuál es la responsabilidad de cierta clase y que muchas veces me encuentro incompatibilidades entre el SRP (Single Responsibility Principle) y otros principios como KISS.

Creo que este principio tiene mucho que ver con la creación de abstracciones mentales por parte del equipo, que a su vez van cambiando a medida que se conoce mejor el problema que se tiene entre manos. Esto añade una dosis de subjetividad a la mezcla que hace que sea un principio que todos creemos entender, pero que seguramente pocos realmente lo hagan.

Hecha esta diatriba, continuemos… ¿Qué pasa si en lugar de guardar el mismo valor en Foo y en Bar queremos guardar el resultado de aplicarle una función como, por ejemplo, el cuadrado, el siguiente entero, o el máximo de una colección?

En este caso estaríamos obligados a cambiar la implementación de Foo… ¡¡¡por un cambio en la especificación de Bar!!! Aquí parece claro que hemos caído en un error de diseño. Y una vez más seguramente porque rails permite hacer muchas cosas de forma muy fácil. Pero como ya hemos dicho, que puedas hacerlo no quiere decir que debas. Y la responsabilidad del código, en última instancia, es tuya.

Para solucionar este acoplamiento nuestra propuesta es mover la lógica de persistencia a una clase de servicio.

Se podrían hacer soluciones más complejas como la utilización de eventos (emitir un evento cada vez que se haga un commit relacionado con Foo, y que Bar se suscriba a dicho evento). Sin embargo, el problema que estamos solucionando aquí es de persistencia de datos (seguramente un problema de caché, puesto que el valor siempre puede calcularse a partir de otro) dentro de un mismo dominio y creemos que en este caso no está justificado. La lógica es suficientemente sencilla, la solución a la transaccionalidad es prácticamente trivial y nos ahorraríamos muchas decisiones de diseño (nombrado de los eventos, formatos, sincronía vs asincronía…).

Como siempre, la mejor solución depende del contexto: base de código, experiencia del equipo, visión de futuro… Pero en nuestra experiencia, la sencillez es un valor y en el 99% de los casos que nos hemos encontrado no haría falta hacer nada más que una clase de servicio.

Por último, mientras escribía este post y como es obvio, ya ha habido gente que ha escrito sobre esto, así que aquí van un par de enlaces que me han parecido interesantes. Como podéis ver, hace 8 años ya se hablaba de esto y sospecho que la razón de que no se haya solucionado tiene que ver con que vivimos en un mercado en el que la mitad de la población de profesionales no supera los cinco años de experiencia (idea que he robado descaradamente de Uncle Bob), pero de eso hablaremos en otro post… algún día.