Más allá de CRUDs: [04] Modelos cover image

Más allá de CRUDs: [04] Modelos

Kenny Horna Lectura de 6 minutos

laravel php programming

En los capítulos anteriores, hemos hablado sobre dos de los tres componentes básicos de cada aplicación: DTO y acciones: datos y funcionalidad. En este capítulo veremos la última pieza que considero parte de este núcleo: exponer los datos que persisten en un almacén de datos; en otras palabras: modelos.


Este es el artículo #04 de la serie Laravel: Más allá de CRUDs. Fue originalmente publicado por Brent en su blog (puedes encontrar ahí la serie en su idioma original).

La tabla de contenido que conforma esta serie la tienes aquí.

Dicho esto, continuemos 😉.


Ahora, los modelos son un tema complicado. Laravel proporciona mucha funcionalidad a través de sus clases modelo Eloquent, lo que significa que no solo representan los datos en un almacén de datos, sino que también te permiten crear consultas, cargar y guardar datos, tienen un sistema de eventos incorporado y más.

En este capítulo, no te diré que abandones toda la funcionalidad del modelo que proporciona Laravel; de hecho, es bastante útil. Sin embargo, mencionaré algunas trampas con las que debes tener cuidado y soluciones para ellas; de modo que incluso en proyectos grandes, los modelos no serán la causa del mantenimiento difícil.

Mi punto de vista es que debemos adoptar el framework, en lugar de tratar de luchar contra él; aunque deberíamos adoptarlo de tal manera que los proyectos grandes se mantengan mantenibles. Así que sumerjámonos.

Modelo ≠ lógica de negocio

El primer inconveniente con el que se encuentran muchos desarrolladores es que piensan en los modelos como el lugar ideal para la lógica de negocio. Ya mencioné algunas responsabilidades de los modelos que están integradas en Laravel, y argumentaría que tengas cuidado de no agregar más.

Suena muy atractivo al principio, poder hacer algo como $invoiceLine->price_incuding_vat o $invoice-> total_price; y seguro que sí. De hecho, creo que las facturas y las líneas de factura deberían tener estos métodos. Sin embargo, hay una distinción importante que hacer: estos métodos no deberían calcular nada. Echemos un vistazo a lo que no hay que hacer:

Aquí hay un descriptor de acceso total_price en nuestro modelo Invoice, recorriendo todas las líneas de factura y haciendo la suma de su precio total.

class Invoice extends Model
{
    public function getTotalPriceAttribute(): int
    {
        return $this->invoiceLines
            ->reduce(function (int $totalPrice, InvoiceLine $invoiceLine) {
                return $totalPrice + $invoiceLine->total_price;
            }, 0);
    }
}

Y así es como se calcula el precio total por línea.

class InvoiceLine extends Model
{
    public function getTotalPriceAttribute(): int
    {
        $vatCalculator = app(VatCalculator::class);

        $price = $this->item_amount * $this->item_price;

        if ($this->price_excluding_vat) {
            $price = $vatCalculator->totalPrice(
                $price, 
                $this->vat_percentage
            );
        }

        return $price;
    }
}

Como leíste el capítulo anterior sobre acciones, puede adivinar lo que haría en su lugar: calcular el precio total de una factura es una historia de usuario que debe representarse mediante una acción.

Los modelos Invoice e InvoiceLine podrían tener las propiedades simples total_price y price_incuding_vat, pero primero se calculan mediante acciones y luego se almacenan en la base de datos. Al usar $invoice-> total_price, simplemente está leyendo datos que ya se han calculado antes.

Hay algunas ventajas en este enfoque. Primero el obvio: rendimiento, solo estás haciendo los cálculos una vez, no siempre cuando necesitas los datos. En segundo lugar, puede consultar los datos calculados directamente. Y tercero: no tiene que preocuparse por los efectos secundarios.

Ahora, podríamos comenzar un debate purista sobre cómo la responsabilidad individual ayuda a que sus clases sean pequeñas, mejor mantenibles y fácilmente comprobables; y cómo la inyección de dependencia es superior a la ubicación del servicio; pero prefiero decir lo obvio en lugar de tener largos debates teóricos donde sé que simplemente hay dos lados que no estarán de acuerdo.

Entonces, lo obvio: aunque te gustaría poder hacer $invoice->send() o $invoice->toPdf(), el código del modelo está creciendo y creciendo. Esto es algo que sucede con el tiempo, no parece ser un gran problema al principio. $invoice->toPdf() en realidad solo puede ser una o dos líneas de código.

Sin embargo, por experiencia, una o dos líneas suman. "Una o dos líneas" no son el problema, pero cien veces "una o dos líneas" sí lo son. La realidad es que las clases modelo crecen con el tiempo y pueden crecer bastante.

Incluso si no estás de acuerdo conmigo en las ventajas que aporta la inyección de responsabilidad individual y de dependencia, hay poco en desacuerdo sobre esto: una clase de modelo con cientos de líneas de código no se puede mantener.

Todo eso para decir esto: piensa en los modelos y su propósito como proporcionarle datos solamente, deja que algo más se preocupe por asegurarse de que los datos se calculen correctamente.

Reducción de modelos

Si nuestro objetivo es mantener las clases de modelos razonablemente pequeñas, lo suficientemente pequeñas como para poder comprenderlas simplemente abriendo su archivo, necesitamos mover algunas cosas más. Idealmente, solo queremos mantener getters y setters, simples accesores y mutadores, casts y relaciones.

Otras responsabilidades deben trasladarse a otras clases. Un ejemplo son los Query Scopes: podríamos moverlos fácilmente a clases generadoras de consultas dedicadas.

Lo creas o no: las clases generadoras de consultas son en realidad la forma normal de usar Eloquent; los scopes son simplemente "versiones endulzadas" encima de estas. Así es como podría verse una clase generadora de consultas.

namespace Domain\Invoices\QueryBuilders;

use Domain\Invoices\States\Paid;
use Illuminate\Database\Eloquent\Builder;

class InvoiceQueryBuilder extends Builder
{
    public function wherePaid(): self
    {
        return $this->whereState('status', Paid::class);
    }
}

A continuación, anulamos el método newEloquentBuilder en nuestro modelo y devolvemos nuestra clase personalizada. Laravel lo usará a partir de ahora.

namespace Domain\Invoices\Models;

use Domain\Invoices\QueryBuilders\InvoiceQueryBuilder;

class Invoice extends Model 
{
    public function newEloquentBuilder($query): InvoiceQueryBuilder
    {
        return new InvoiceQueryBuilder($query);
    }
}

Esto es lo que quise decir al "adoptar el framework": no es necesario introducir nuevos patrones como repositorios per se, puedes construir sobre lo que ya proporciona Laravel. Pensándolo bien, logramos el equilibrio perfecto entre el uso de los productos proporcionados por el framework y la prevención de que nuestro código crezca demasiado en lugares específicos.

Con esta mentalidad, también podemos proporcionar clases collection personalizadas para las relaciones. Laravel tiene un gran soporte de colecciones, aunque a menudo terminas con largas cadenas de funciones de esta clase, ya sea en el modelo o en la capa de aplicación. Esto -nuevamente- no es ideal, y afortunadamente Laravel nos proporciona los ganchos necesarios para agrupar la lógica de las colecciones en una clase dedicada.

Aquí hay un ejemplo de una clase collection personalizada, y ten en cuenta que es completamente posible combinar varios métodos en otros nuevos, evitando largas cadenas de funciones en otros lugares.

namespace Domain\Invoices\Collections;

use Domain\Invoices\Models\InvoiceLines;
use Illuminate\Database\Eloquent\Collection;

class InvoiceLineCollection extends Collection
{
    public function creditLines(): self
    {
        return $this->filter(function (InvoiceLine $invoiceLine) {
            return $invoiceLine->isCreditLine();
        });
    }
}

Así es como vinculas una clase collection con un modelo; InvoiceLine, en este caso:

namespace Domain\Invoices\Models;

use Domain\Invoices\Collection\InvoiceLineCollection;

class InvoiceLine extends Model 
{
    public function newCollection(array $models = []): InvoiceLineCollection
    {
        return new InvoiceLineCollection($models);
    }

    public function isCreditLine(): bool
    {
        return $this->price < 0.0;
    }
}

Cada modelo que tenga una relación HasMany con InvoiceLine, ahora usará nuestra clase collection en su lugar.

$invoice
    ->invoiceLines
    ->creditLines()
    ->map(function (InvoiceLine $invoiceLine) {
        // …
    });

Intenta mantener tus modelos limpios y orientados a los datos, en lugar de hacer que proporcionen lógica de negocio. Hay mejores lugares para manejarlo.