Ruby on Rails, Laravel, CodeIgniter, etc.. todos usan el patrón Active Record, el cual permite un desarrollo super rápido. Es sencillo de usar, fácil de aprender, buen rendimiento, pero.. ¿por qué acaban mal los proyectos cuando escalan?
La idea general de este patrón es simple: Por cada tabla de la base de datos, tenemos una clase que gestiona la interacción con esta tabla.
Consistencia
Inicialmente es muy sencillo, pero no suele escalar bien, vamos a ver un ejemplo:
Imagina que tenemos una tabla Orders, y que tenemos un botón “Enviar” que marca el pedido como enviado.
En el controlador tendríamos algo como:
$order = Order::query()->findOrFail($orderId);
$order->status = OrderStatusEnum::sent;
$order->save();
Otro de los desarrolladores está con la funcionalidad de conectar Correos Express para enviar los pedidos, y acaba con un código de este tipo:
public function sendByCorresExpress(OrderId $orderId): void {
//Comunicación API Correos
.....
.....
$order = Order::query()->findOrFail($orderId);
$order->status = OrderStatusEnum::sent;
$order->sentDate = time();
$order->save();
Ahora tenemos dos controladores que marcan el pedido como enviado, uno de ellos pone la fecha de envío, el otro no.
Para los envíos internacionales, ahora deciden integrarse con DHL, con lo cual tenemos:
public function sendByDHL(OrderId $orderId): void {
//Comunicación API Correos
.....
.....
$order = Order::query()->findOrFail($orderId);
$order->status = OrderStatusEnum::sent;
$order->sentDate = time();
$order->trackingNumber = $dhlTrackingNumber;
$order->save();
Para poder hacer el tracking, se agrega un campo nuevo.
El botón de Enviar inicial, quieren dejarlo para pedidos manuales, es algo que se suele hacer.
Los requisitos de esta funcionalidad, indican que es necesario agregar un campo nuevo, para establecer la agencia de transportes usada en el pedido.
$order = Order::query()->findOrFail($orderId);
$order->status = OrderStatusEnum::sent;
$order->carrier = CarrierEnum::manual;
$order->save();
El programador lo cambia también en DHL, pero se olvida de hacerlo en correos Express.
¿Que está ocurriendo?
Si miramos los registros en la tabla orders, tendremos algunos sin fecha de envío, otros sin agencia y otros sin tracking. Tenemos una tabla totalmente inconsistente.
Lo que está ocurriendo está causado por usar lógica de negocio implicita en lugar de explicita. Y además duplicando la lógica en varios controladores.
El programador ha considerado que cambiar el estado a enviado, implicitamente ha puesto el pedido como enviado “enviar pedido”, pero no es así.
A medida que el software crece, cada vez agregamos más funcionalidad, se vuelve más inconsistente.
Veamos ahora la solución explicita:
$order = Order::query()->findOrFail($orderId);
$order->sent(carrier: CarrierEnum::manual, tracking: $trackingNumber);
$order->save();
Y dentro de Order tendremos:
public function send(CarrierEnum $carrier, string $tracking): void
{
$valid = $this->validateForSend();
if ($valid === false) {
throw new OrderNotValidForShippingException();
}
$this->status = OrderStatusEnum::shipping;
$this->tracking = $tracking;
$this->carrier = $carrier;
$this->sentDate = time();
}
Con esta solución, se consigue una consistencia del objeto Order, que no puede romperse por cambios sucesivos en los requisitos.
Acoplamiento
Cuando trabajaba en Veepee, teníamos un campo que se llamaba “shop_cost” que no era el precio de coste, el precio de venta. Pero el campo se usaba en toda la aplicación y el impacto de cambiarlo era tan grande que se quedó mal.
A lo largo de 6 años de desarrollo, más del 20% de los campos estaban mal, pero no podíamos hacer nada sin que todo se rompiera. Vamos a ver el motivo.
Cómo os he comentado, en Active Record, hay una clase por cada tabla, y una propiedad por cada campo.
/**
* @property string $name
* @property double $shop_cost
*/
final class Product extends Model
{
protected $fillable = [
'name',
'shop_cost',
];
}
Para empezar, me veo obligado a usar snake case para las propiedades, debido al fuerte acoplamiento entre la base de datos y el ActiveRecord.
Pero esta no es la parte más peligrosa. ¿En cuantos sitios hemos usado shop_cost?
- Al devolver el listado de productos
- Al agregarlo al carrito
- Al agregarlo al pedido
- Al calcular el total del pedido
- En los informes
- API
- Etc…
El problema de acoplar el Active Record en todas las capas es que el modelo de la base de datos se propaga directamente al API y al frontend, lo que dificulta la flexibilidad y la capacidad de cambiar la lógica de negocio sin afectar todas las capas de la aplicación.
¿Solución?
No hay una solución sencilla con Active Record, pero si una serie de recomendaciones que puede ayudar a reducir el acoplamiento.
Una de las primeras cosas que puedes hacer, es intentar tener al menos el ActiveRecord limpio, aunque el campo en la BBDD este mal. En Laravel por ejemplo se puede hacer con accessors y mutators. Que permiten crear propiedades que no existen y mapearlas a otras.
public function getPriceAttribute()
{
return $this->attributes['shop_cost'];
}
public function setPriceAttribute($value)
{
$this->attributes['shop_cost'] = $value;
}
De esta forma podemos desacoplar el modelo del la tabla en la bbdd.
En segundo lugar intentaremos no devolver nunca un Active Record directamente por API. En su lugar usaremos un archivo de recursos. Laravel proporciona una fuerte funcionalidad para gestionar “Resources” en las peticiones API, aunque yo personalmente prefiero hacerlo de la siguiente forma:
final class ProductResource implements JsonSerializable
{
public function __construct(
public ProductId $id,
public float $price,
)
{
}
public function jsonSerialize(): array
{
return [
'id' => $this->id->value,
'price' => $this->price,
];
}
}
De esta forma, tenemos desacoplada la información que se envía via API, siendo esta un contrato que no debería modificarse a la ligera.
Con estas dos técnicas, conseguimos el suficiente desacoplamiento para poder hacer cambios con menos impacto y más control.
Agregados
Un pedido sin líneas no es una unidad mínima de negocio válida. Pero sin embargo con Active Record puedo crear un pedido sin lineas.
Y más aún, puedo manipular las lineas de forma independiente al pedido. Esto puede crear una inconsistencia en el pedido, al mismo tiempo que impide tener una regla de negocio rica, complicando mucho cuando el software crece.
Vamos a un ejemplo:
- Queremos una propiedad que sea “total del pedido”, y si este total supera 500€, los gastos de envío son gratis.
Con Active Record sería algo como:
$order = new Order();
$order->save();
$order->lines()->save(new OrderLine(id: $productId, qtd: $quantity));
$order->total = $order->lines()->sum('price');
if ($order->total > 500) {
$order->shippingCosts = 0;
}
$order->save();
Problemas:
- Es necesario guardar el pedido para poder agregarle lineas, con lo cual, durante un momento está con un estado incongruente.
- ¿Qué ocurre si alguien modifica una linea de pedido, ya que Linea de Pedido es también un Active Record y puede ser manipulado directamente?
- ¿Y si para calcular el total necesitamos una lógica de negocio definida para cada tipo de impuesto?
- Hay una segunda operación para guardar el pedido.
La solución a este problema en Active Record es un poco jodida. No hay buenas soluciones, sólo formas de mitigar el problema. Como ejemplo:
final class Order extends Model
{
public function addLine(OrderLine $line): void
{
$this->lines[] = $line;
$this->total += $line->getTotal();
}
public function hasFreeShipping(): bool
{
return $this->freeShipping || $this->total > 500;
}
}
En este caso OrderLine no es un Active Record, es un objeto de negocio que manualmente tenemos que guardar y recuperar de bbdd.
Es un sobrecoste que es rentable a medio/largo plazo. En mi caso, yo uso dos objetos, OrderLine y OrderLineAR. Y tengo un mapper que copia los valores de uno a otro y los sincroniza.
Resumen
El último proyecto que hice con Active Record, que después de 10 años sigue funcionando, está lleno de de incoherencias y problemas que se podían haber evitado:
- Pedidos pendientes con fecha de envío.
- Pedidos enviados sin fecha de envío.
- Pedidos en los que el total no es igual a la suma de las líneas.
- Pedidos sin líneas.
- Líneas sin pedidos.
Poco a poco se fueron metiendo reglas de validación, procesos batch de corrección de datos, constraints en base datos etc… Pero no deja de ser un gran parche.
Leave a Reply