Observadores modelo: ¿una mala práctica?

observadores modelo

¿Por qué los observadores modelo en Laravel son una mala práctica?

Observadores modelo — Laravel proporciona una forma interesante de automatizar eventos de modelos comunes dentro de su aplicación con eventos enviados, eventos de cierre y observadores.

Si bien suena genial tener este tipo de solución plug and play, hay ciertos casos en los que esto resultará contraproducente para su proyecto si tiende a sobrecargar esta función con la lógica comercial.


TL;RD

  • Creo que los observadores modelo y los eventos modelo están bien para MVP y/o proyectos más pequeños.
  • Cuando tiene más de 2 desarrolladores trabajando y/o más de 100 casos de prueba, pueden convertirse en un problema (no en absoluto).
  • Para proyectos muy grandes, eso será un problema seguro. Tendría que dedicar mucho tiempo a la refactorización, control de calidad y pruebas de regresión de su aplicación. Así que piense en el futuro y refactorice temprano.
  • Motivo: los eventos del modelo crean efectos secundarios ocultos, a veces inesperados y no requeridos por la acción ejecutada.

Los efectos secundarios más comunes se pueden observar al escribir y ejecutar pruebas unitarias y pruebas de características para su aplicación Laravel. Este artículo demostrará este escenario.


Nuestro ejemplo

Procesar medidas de temperatura de dispositivos IoT, almacenarlas en una base de datos y realizar algunos cálculos adicionales después de cada muestra consumida.

Nuestros requisitos comerciales:

  • Almacenar una muestra consumida a través de la API expuesta
  • Para cada actualización de muestra almacenada y temperatura promedio para las últimas 10 medidas

Este es nuestro modelo de muestra y migración:

<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('samples', static function (Blueprint $table) {
            $table->id();
            $table->string('device_id');
            $table->float('temp');
            $table->timestamp('created_at')->useCurrent();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('samples');
    }
};
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Sample extends Model
{
    use HasFactory;

    public $timestamps = false;

    protected $fillable = [
        'device_id',
        'temp',
        'created_at',
    ];
}

Ahora, cada vez que almacenamos una muestra, queremos almacenar la temperatura promedio de las últimas 10 muestras en otro modelo, Avg Temperature:

<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('avg_temperatures', static function (Blueprint $table) {
            $table->id();
            $table->string('device_id');
            $table->float('temp');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('avg_temperatures');
    }
};
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class AvgTemperature extends Model
{
    use HasFactory;

    protected $fillable = [
        'device_id',
        'temp',
    ];
}

Podemos lograr esto simplemente adjuntando un evento al estado  created del modelo de Sample:

<?php

declare(strict_types=1);

namespace App\Models;

use App\Events\SampleCreatedEvent;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Sample extends Model
{
    use HasFactory;

    public $timestamps = false;

    protected $fillable = [
        'device_id',
        'temp',
        'created_at',
    ];

    /**
     * @var array<string, string>
     */
    protected $dispatchesEvents = [
        'created' => SampleCreatedEvent::class,
    ];
}

Ahora agregamos un oyente con lógica de recálculo promedio:

class EventServiceProvider extends ServiceProvider
{
    /**
     * @var array<string, array<string>>
     */
    protected $listen = [
        SampleCreatedEvent::class => [
            RecalcAvgTemperatureListener::class,
        ],
    ];
}
<?php

declare(strict_types=1);

namespace App\Listeners;

use App\Events\SampleCreatedEvent;
use App\Models\AvgTemperature;
use App\Models\Sample;

class RecalcAvgTemperatureListener
{
    public function handle(SampleCreatedEvent $event): void
    {
        $average = Sample::orderByDesc('created_at')
            ->limit(10)
            ->avg('temp');

        AvgTemperature::updateOrCreate([
            'device_id' => $event->sample->device_id,
        ], [
            'temp' => $average ?? 0,
        ]);
    }
}

Ahora, nuestra implementación de controlador ingenuo, **omitiendo la validación y cualquier buen patrón de desarrollo**, se vería así:

<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Sample;
use Illuminate\Http\Request;

class SampleController extends Controller
{
    public function store(Request $request): void
    {
        Sample::create(
            array_merge($request->all(), ['created_at' => now()])
        );
    }
}

También podemos escribir una prueba de función que confirme que nuestra ruta API funciona como se esperaba: la muestra se almacena y la muestra promedio se almacena:

<?php

declare(strict_types=1);

namespace Tests\Original;

use App\Models\AvgTemperature;
use App\Models\Sample;
use Tests\TestCase;

class SampleControllerTest extends TestCase
{
    /** @test */
    public function when_sample_is_sent_then_model_is_stored(): void
    {
        // act
        $this->post('/sample', [
            'device_id' => 'xyz',
            'temp'      => 10.5,
        ]);

        // assert
        $sample = Sample::first();
        $this->assertSame('xyz', $sample->device_id);
        $this->assertSame(10.5, $sample->temp);
    }

    /** @test */
    public function when_sample_is_sent_then_avg_model_is_stored(): void
    {
        Sample::factory()->create(['device_id' => 'xyz', 'temp' => 20]);

        // act
        $this->post('/sample', [
            'device_id' => 'xyz',
            'temp'      => 10,
        ]);

        // assert
        $sample = AvgTemperature::first();
        $this->assertSame('xyz', $sample->device_id);
        $this->assertSame(15.0, $sample->temp);
    }
}
Resultados de la ejecución de prueba

Eso se ve perfectamente bien, ¿verdad?


Ahora cuando las cosas van mal

Imagine que un segundo desarrollador de su equipo va a escribir una prueba de unidad donde quiere verificar los cálculos de temperatura promedio.

Extrae un servicio de la clase de escucha para hacer este trabajo:

<?php

declare(strict_types=1);

namespace App\Listeners;

use App\Events\SampleCreatedEvent;
use App\Services\AvgTemperatureRecalcService;

class RefactoredRecalcAvgTemperatureListener
{
    public function __construct(protected AvgTemperatureRecalcService $recalcAvg)
    {
    }

    public function handle(SampleCreatedEvent $event): void
    {
        $this->recalcAvg->withLatestTenSamples($event->sample);
    }
}
<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\AvgTemperature;
use App\Models\RefactoredSample;
use App\Models\Sample;

class AvgTemperatureRecalcService
{
    public function withLatestTenSamples(Sample|RefactoredSample $sample): void
    {
        $average = Sample::where('device_id', $sample->device_id)
            ->orderByDesc('created_at')
            ->limit(10)
            ->pluck('temp')
            ->avg();

        AvgTemperature::updateOrCreate([
            'device_id' => $sample->device_id,
        ], [
            'temp' => $average ?? 0,
        ]);
    }
}

Tiene esta prueba de unidad escrita donde quiere sembrar 100 muestras a la vez con intervalos de 1 minuto:

<?php

declare(strict_types=1);

namespace Tests\Original;

use App\Models\AvgTemperature;
use App\Models\Sample;
use App\Services\AvgTemperatureRecalcService;
use Tests\TestCase;

class AvgTemperatureRecalcServiceTest extends TestCase
{
    /** @test */
    public function when_has_existing_100_samples_then_10_last_average_is_correct(): void
    {
        for ($i = 0; $i < 100; $i++) {
            Sample::factory()->create([
                'device_id'  => 'xyz',
                'temp'       => 1,
                'created_at' => now()->subMinutes($i),
            ]);
        }
        $sample = Sample::factory()->create(['device_id' => 'xyz', 'temp' => 11, 'created_at' => now()]);

        // pre assert
        // this will FAIL because average was already recounted 100x times when factory was creating 100x samples
        $this->assertCount(0, AvgTemperature::all());

        // act
        $service = new AvgTemperatureRecalcService();
        $service->withLatestTenSamples($sample);

        // assert
        $avgTemp = AvgTemperature::where('device_id', 'xyz')->first();
        $this->assertSame((float)((9 + 11) / 10), $avgTemp->temp);
    }
}
observadores modelo
La prueba falla en la línea 28

Este es un ejemplo bastante simple y se puede solucionar deshabilitando el evento del modelo o falsificando toda la fachada del evento de forma ad hoc.

Event::fake();orSample::unsetEventDispatcher();

Para cualquier proyecto más o menos grande, estas opciones son dolorosas: siempre debe recordar que su modelo crea efectos secundarios.

Imagine que tal evento crea efectos secundarios en otra base de datos o un servicio externo a través de una llamada API. Cada vez que creas una muestra con una fábrica, tienes que lidiar con llamadas externas simuladas.

Lo que tenemos aquí es una combinación de un mal patrón de desarrollo de los eventos del modelo y un desacoplamiento de código insuficiente.


Refactorizando y desacoplando nuestro ejemplo

Para una mejor visibilidad, crearemos un segundo conjunto de modelos en nuestro proyecto y una nueva ruta.

Primero eliminamos el evento del modelo de nuestro modelo de muestra, ahora se ve así:

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class RefactoredSample extends Model
{
    use HasFactory;

    protected $table = 'samples';

    public $timestamps = false;

    protected $fillable = [
        'device_id',
        'temp',
        'created_at',
    ];
}

Luego creamos un servicio que se encargará de consumir nueva muestra:

<?php

declare(strict_types=1);

namespace App\Services;

use App\Events\SampleCreatedEvent;
use App\Models\DataTransferObjects\SampleDto;
use App\Models\RefactoredSample;

class SampleConsumeService
{
    public function newSample(SampleDto $sample): RefactoredSample
    {
        $sample = RefactoredSample::create([
            'device_id'  => $sample->device_id,
            'temp'       => $sample->temp,
            'created_at' => now(),
        ]);

        event(new SampleCreatedEvent($sample));

        return $sample;
    }
}

Observe que nuestro servicio ahora es responsable de activar un evento en caso de éxito.

Nuestro nuevo controlador de ruta se verá así:

<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Http\Requests\StoreSampleRequest;
use App\Models\DataTransferObjects\SampleDto;
use App\Services\SampleConsumeService;

class SampleController extends Controller
{
    public function storeRefactored(StoreSampleRequest $request, SampleConsumeService $service): void
    {
        $service->newSample(SampleDto::fromRequest($request));
    }
}

Solicitar clase:

<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

/**
 * @property-read string $device_id
 * @property-read string|float|int $temp
 */
class StoreSampleRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, array<string>>
     */
    public function rules(): array
    {
        return [
            'device_id' => ['required', 'string'],
            'temp'      => ['required', 'numeric'],
        ];
    }
}

Ahora replicamos nuestras segundas pruebas de desarrolladores con la nueva ruta y podemos confirmar que pasa:

<?php

declare(strict_types=1);

namespace Tests\Refactored;

use App\Models\AvgTemperature;
use App\Models\RefactoredSample;
use App\Services\RefactoredAvgTemperatureRecalcService;
use Tests\TestCase;

class AvgTemperatureRecalcServiceTest extends TestCase
{
    /** @test */
    public function when_has_existing_100_samples_then_10_last_average_is_correct(): void
    {
        for ($i = 0; $i < 100; $i++) {
            RefactoredSample::factory()->create([
                'device_id'  => 'xyz',
                'temp'       => 1,
                'created_at' => now()->subMinutes($i),
            ]);
        }
        $sample = RefactoredSample::factory()->create(['device_id' => 'xyz', 'temp' => 11, 'created_at' => now()]);

        // pre assert
        $this->assertCount(0, AvgTemperature::all());

        // act
        $service = new RefactoredAvgTemperatureRecalcService();
        $service->withLatestTenSamples($sample);

        // assert
        $avgTemp = AvgTemperature::where('device_id', 'xyz')->first();
        $this->assertSame((float)((9 + 11) / 10), $avgTemp->temp);
    }
}
observadores modelo
Aprobar prueba unitaria

Conclusión

Lo que se mejoró:

  • Desacoplamos nuestro controlador del modelo de base de datos.
  • Desacoplamos el procesamiento de muestras (lógica empresarial) del marco.
  • La activación de  SampleCreatedEvent es más controlable y no se activará cuando no se espere.

Cómo ayuda esto:

  • Los desarrolladores están más contentos cuando trabajan con su código.
  • Ahora puede simular el procesamiento de muestras al probar el controlador de muestras.
  • CI/CD corre más rápido y cuesta menos ya que no hacemos trabajo innecesario (válido para proyectos grandes).

El repositorio con código se puede encontrar aquí: https://github.com/dkhorev/model-observers-bad-practice.


Si le interesa, puede echar un vistazo a algunos de los otros artículos que he escrito recientemente sobre Laravel:

Recent Post