Desenvolupem la teva web presencial SPA DES DE 300€. Si, es una bogeria. Web SPA DES DE 300€ — Si, es una bogeria. Parlem →

On posar la lògica de negoci si no la vols al controlador ni al model

On posar la lògica de negoci si no la vols al controlador ni al model

Gairebé cap projecte decideix de manera conscient on viu la seva lògica de negoci. Es va acumulant on toca en cada moment i, quan te'n vols adonar, la resposta a "on es fa això" depèn de qui ho va escriure. Recorrerem les tres opcions habituals, veurem què es trenca exactament en cadascuna i què guanya una operació de negoci quan té una classe per a ella sola, inclòs el que costa. A AndorraDev treballem sobretot amb Laravel, així que els exemples van amb el seu ecosistema, però la discussió és la mateixa a Symfony, Rails o Django i el criteri es trasllada sencer.

El controlador no és el lloc, però el model tampoc

Tota aplicació arriba al mateix punt. Comences escrivint la lògica al controlador perquè és on entra la petició, i funciona durant uns mesos. Després aquell controlador té dotze mètodes, cadascun fa cinc coses i ningú sap quina d'elles s'executa també des d'un altre lloc.

La reacció habitual és moure-ho tot al model. I allà el problema canvia de forma però no de mida: el model passa a saber de correus, de facturació, de PDF i de cues, quan la seva feina era representar una fila d'una taula.

La tercera via, les classes de servei, és millor. Però té un forat que es nota al cap de dos anys.

Què falla a les tres respostes habituals

Al controlador, la lògica queda lligada a una petició HTTP. El dia que necessites executar el mateix des d'una comanda programada, des d'una cua o des d'un altre punt de l'aplicació, copies i enganxes. A partir d'aquí hi ha dues versions de la mateixa regla de negoci i només se'n corregeix una quan canvia.

Al model, qualsevol mètode pot tocar qualsevol cosa, així que el fitxer creix sense sostre. I com que és un objecte que s'instancia per tot arreu, és impossible saber des d'on es crida a què.

En una classe de servei el codi queda raonablement ordenat, però no hi ha convenció d'invocació. Cada servei decideix si s'instancia, s'injecta o és estàtic, i això ho decideix qui el va escriure aquell dia. I hi ha un segon cost: quan aquesta mateixa operació s'ha d'executar en segon pla, escrius un Job a part que repeteix la signatura i reenvia la crida. Dues classes per a una sola operació.

Una operació de negoci és una classe amb un mètode

La idea del patró d'accions és simple: una operació, una classe, un mètode. Es crida igual des d'on sigui, i el nom de la classe diu què fa.

Així es veu una de real, d'una plataforma legal que hem construït:

class GenerateQrCodeAction extends Action
{
    protected CaseModel $caseModel;

    public function handle(int $size = 150): string
    {
        $url = route('cases.show', $this->caseModel->slug);

        return (string) QrCode::size($size)
            ->style('round')
            ->margin(1)
            ->generate($url);
    }
}

És tot. No hereta d'un controlador, no viu dins del model i no necessita que ningú recordi com s'instancia.

La mateixa classe s'executa ara o en segon pla

Aquí hi ha la diferència que de debò importa respecte a una classe de servei. La mateixa acció s'executa de les dues maneres sense escriure un Job a part:

$this->file->action('resize_image')->run();
$this->file->action('auto_tag')->queue('default')->dispatch();

Aquest fragment és real, d'Abodara, i és al mateix mètode. El redimensionament es fa a l'instant perquè l'usuari està esperant a veure la imatge. L'etiquetatge automàtic, que crida un model de visió i triga, se'n va a la cua. Dos comportaments, una sola classe, cap duplicació.

Els reintents i el retard es declaren a la mateixa acció, no al punt de crida:

class AutoTagAction extends Action
{
    public File $file;

    protected int $tries = 5;
    protected ?int $delay = 30;

Registrar les accions al model que les fa servir

Un cop les operacions viuen en classes soltes apareix un problema nou: trobar-les. Res del model no diu què s'hi pot fer, i aquesta informació acaba repartida entre controladors i vistes. Declarar-la al mateix model la torna a un sol lloc:

protected array $actions = [
    'generate_qr'       => GenerateQrCodeAction::class,
    'create_slot'       => CreateCaseSlotAction::class,
    'add_fee'           => AddCaseFeeAction::class,
    'create_proforma'   => CreateProformaAction::class,
    'send_email'        => SendCaseEmailAction::class,
];

I a partir d'aquí, $case->action('send_email')->run([...]). El model no conté la lògica, però sí que publica el catàleg del que se li pot fer. Obrir aquest fitxer i llegir la llista és la documentació més útil que tindràs del domini.

A Abodara hi ha un model File que registra setze operacions, des de generar variants fins a aplicar una marca d'aigua. Cap viu dins del model.

Andie recomana

Declara sempre els valors per defecte a la signatura de handle() i valida-hi dins. Així l'acció documenta el seu propi contracte: qui la llegeix sap què espera i què passa si alguna cosa no arriba, sense haver d'anar a buscar qui la invoca. És el que fa que la mateixa acció serveixi igual des d'un controlador, des d'una cua o des de l'eina d'un agent d'IA.

Què es guanya quan dos llocs diferents criden el mateix

El cas que justifica el patró sencer va aparèixer en muntar el copilot d'IA d'una plataforma legal. Hi ha una operació, redactar una clàusula a partir d'una descripció, que s'invoca des de dos llocs que no s'assemblen gens: un component de la interfície i una eina de l'agent d'IA.

Sense accions, això són dues implementacions. Amb una acció, són dues línies gairebé idèntiques cridant la mateixa classe:

// Des del component de la interfície
$result = DraftClauseAction::create()->actor(auth()->user())->run([...]);

// Des de l'eina de l'agent
$result = DraftClauseAction::create()->actor($user)->run([...]);

La validació, la crida al model de llenguatge i el desat de l'esborrany viuen en un sol lloc. Quan canviï la regla, canvia una vegada.

Aquest actor() tampoc és decoratiu: registra qui executa l'operació, que en un producte amb equips i permisos és la diferència entre poder auditar i no poder. En aquella plataforma es fa servir en 41 crides.

Què hi ha al mercat i en què es diferencia

L'alternativa coneguda a Laravel és lorisleiva/laravel-actions, i és un paquet sòlid. La diferència tècnica és real i convé entendre-la, perquè no van al mateix.

Aquell paquet converteix una acció en moltes coses alhora: es pot registrar com a controlador en una ruta, actuar de listener d'esdeveniments i de comanda d'artisan. És més superfície, i per a qui vulgui aquella unificació està ben resolta.

Laractions, que és el que mantenim i fem servir, fa deliberadament menys. Se centra en una sola pregunta, on viu una operació de negoci, i a canvi porta dues coses que l'altre no té: el registre declaratiu al model que veies més amunt, i el concepte explícit d'actor, amb auditoria opcional a base de dades.

Si el que busques és que les teves accions siguin també les teves rutes i les teves comandes, la tria és l'altra. Si el que vols és treure la lògica de negoci del controlador i del model deixant un catàleg llegible per model, i poder executar-ho síncron o encuat sense escriure res més, aquest encaixa millor. És la raó per la qual el vam escriure: aquell era el problema que teníem.

Tres detalls que convé saber abans

Cap patró no surt de franc, i aquest cobra en llocs concrets que convé conèixer abans de reescriure mig projecte amb ell. Cap no és descalificador, però els tres t'apareixeran.

Heretar consumeix la teva única herència. Una acció fa extends Action, així que aquella classe ja no pot estendre cap altra. A la pràctica una acció no sol heretar de res més, però convé saber-ho.

El despatx a cua és deliberadament simple. Encuar retorna void, així que no tens cadenes, lots ni afterCommit. Per a orquestracions complexes de feines en segon pla continues necessitant els Jobs de Laravel; això cobreix el cas de "això triga, fes-ho després", que és el 90%.

La validació integrada cobreix els paràmetres del mètode. El que arriba com a propietat de l'acció el valides tu on et convingui. Val la pena decidir-ho al principi i ser-hi consistent, perquè moure una dada d'un lloc a l'altre canvia qui la comprova.

I si no treballes amb Laravel

El patró no és de Laravel ni de PHP. A Symfony l'equivalent natural són els message handlers de Messenger, que ja separen l'operació de qui la invoca i et donen el mateix síncron o asíncron. A Node, un mòdul per cas d'ús amb una única funció exportada aconsegueix el mateix sense cap llibreria.

El que importa és el criteri: una operació de negoci ha de poder invocar-se igual des d'una petició, des d'una cua i des d'una prova, i el seu nom ha de dir què fa. Si el teu codi compleix això, tant se val com ho anomenis.

On és el codi

El paquet està publicat a Packagist, el codi a GitHub i la documentació del paquet recull totes les opcions. És gratuït, i entre les dues plataformes que hem esmentat suma 81 accions en producció.

Si estàs amb una aplicació que ha crescut i ja no saps on ficar cada cosa, és exactament el problema que treballem a aplicacions web a mida i desenvolupament Laravel. Explica'ns-ho i hi fem una ullada.

Escrit per
Edu Lazaro
Edu Lazaro
Founder & Lead Developer en AndorraDev

Desenvolupador full-stack amb més de 15 anys d'experiència en Laravel, React, Node.js i arquitectures cloud. Ajudo empreses a Andorra a construir la seva presència digital.

Partner de diseño · ionospace.
Necessites ajuda? ×
Andie by AndorraDev
Assistent IA + equip humà
Assistent IA d'AndorraDev
Andie
Hola! Soc Andie, l'assistent IA d'AndorraDev. En què et puc ajudar? Si necessites parlar amb Edu, només demana-ho.
17:28