CQRS e Action Classes: oltre il flusso MVC di Laravel

Perché separare scrittura e lettura smonta la catena Route → Controller → Model → View, e come si orchestrano Form Request, DTO, Action ed eventi in un’unica operazione atomica

Il flusso MVC di Laravel è una delle cose meglio progettate del framework, e per la maggior parte delle applicazioni è la risposta giusta. Arriva una richiesta, la rotta la manda a un controller, il controller valida con un Form Request, parla con un modello Eloquent e restituisce una vista o una risorsa JSON. Quattro passaggi, un oggetto centrale — il modello — che sa sia leggere sia scrivere.

Il problema nasce quando lettura e scrittura cominciano a volere cose diverse. La scrittura vuole un oggetto ricco di regole, che protegga le proprie invarianti e rifiuti gli stati impossibili. La lettura vuole dodici colonne provenienti da cinque tabelle, ordinate e paginate, il più rapidamente possibile. Un unico modello Eloquent che serve entrambe le esigenze finisce per servirne male almeno una: o si appesantisce la scrittura di relazioni e accessor che servono solo alle schermate, o si svuota il modello per fare in fretta e le regole migrano altrove.

Il CQRS — Command Query Responsibility Segregation — è la decisione di smettere di fingere che siano la stessa cosa. Scrivere e leggere diventano due percorsi distinti, con oggetti diversi, strutture diverse e criteri di progettazione diversi. E nel momento in cui si prende quella decisione, il flusso MVC classico non si adatta: si rompe in due, e il controller perde il suo ruolo di coordinatore per diventare un traduttore.

Questo articolo descrive come avviene quella rottura, quali pezzi vanno orchestrati al posto del vecchio flusso, e — soprattutto — come tenere atomica un’operazione che adesso coinvolge un Form Request, un DTO, un’Action, una transazione e una catena di eventi. Con un avvertimento in apertura: il CQRS si presenta in tre intensità molto diverse, e la penultima sezione serve a capire a quale fermarsi.

Il flusso MVC standard e il punto esatto in cui si rompe

Conviene mettere i due schemi uno sotto l’altro, perché la differenza si vede meglio di quanto si spieghi.

MVC classico — un percorso, un modello al centro

  Request ──► Route ──► Controller ──► FormRequest (valida)
                             │
                             ├──► Model (Eloquent) ◄──► Database
                             │        legge E scrive
                             │
                             └──► View / Resource ──► Response


CQRS — due percorsi, nessun oggetto al centro

  Request ──► Route ──► Controller
                             │
     scrittura               │               lettura
  ┌──────────────────────────┴───────────────────────────┐
  │                                                      │
  FormRequest (valida al confine)                   QueryObject
  │                                                      │
  DTO / Command (dati tipizzati)                         │
  │                                                      │
  Action / Handler ──► Aggregato ──► transazione         │
  │                        │                             │
  │                   Eventi di dominio                  │
  │                        │                             │
  │                   (dopo il commit)                   │
  │                        └──► aggiorna il read model ──┤
  │                                                      │
  └──► esito ──────────────────────────► DTO di lettura ──┘
                                              │
                                        Resource / View ──► Response

Il punto di rottura è preciso ed è uno solo: nel flusso MVC il modello è il centro di gravità, nel CQRS non esiste un centro. Da questa singola differenza derivano quattro conseguenze pratiche, e vale la pena nominarle perché sono quelle con cui si convive ogni giorno.

1. Il controller smette di orchestrare

Nel flusso classico il controller è il direttore d’orchestra: valida, carica, decide, salva, risponde. Nel CQRS diventa un traduttore con tre compiti — convertire HTTP in un oggetto di dominio, consegnarlo a chi lo esegue, tradurre l’esito in una risposta. Niente logica, niente decisioni, niente accesso diretto alla persistenza.

2. La “M” di MVC si sdoppia

Da un lato c’è il modello di scrittura: l’aggregato con le sue regole, che si carica interamente e si salva interamente. Dall’altro il modello di lettura: oggetti piatti, di sola lettura, costruiti su misura per una schermata specifica. Non sono due versioni della stessa classe: sono due classi senza niente in comune, e questa è la caratteristica che sorprende di più chi arriva da MVC.

3. La validazione si separa in due livelli

Il Form Request valida la forma dell’input — campi presenti, tipi corretti, formati validi — al confine HTTP. Le regole di dominio — questo ordine è confermabile, questo cliente ha credito sufficiente — si spostano dentro l’aggregato e l’Action, perché devono valere anche quando la richiesta arriva da un comando Artisan o da una coda, dove nessun Form Request esiste.

4. L’atomicità diventa un problema esplicito

Questa è la conseguenza più sottovalutata. Nel flusso classico un $modello->save() è atomico per costruzione. Nel CQRS una singola operazione tocca più aggregati, pubblica eventi, aggiorna un read model e magari accoda un job: niente di tutto questo è atomico da solo, e tenerlo coerente è un requisito di progettazione, non un dettaglio di implementazione.

Che cos’è il CQRS, e che cosa non è

Il termine ha una genealogia precisa. Discende dalla Command-Query Separation di Bertrand Meyer, il principio secondo cui un metodo o restituisce un valore o modifica lo stato, mai entrambe le cose. Greg Young ne ha esteso l’idea dal metodo all’oggetto: invece di un oggetto che fa entrambe, due oggetti separati — uno per i comandi, uno per le interrogazioni.

Da qui nascono tre malintesi che conviene smontare subito, perché sono la ragione per cui molti progetti abbandonano il pattern dopo due settimane.

CQRS non è Event Sourcing. Sono pattern che si usano spesso insieme e che non hanno nessuna dipendenza reciproca. Si può fare CQRS salvando righe normali in tabelle normali, senza registrare un solo evento in uno store. Confonderli porta ad adottare l’Event Sourcing — che ha un costo di complessità alto e richiede strumenti dedicati — credendolo necessario.

CQRS non richiede due database. La separazione è di modelli e di percorsi nel codice, non necessariamente di infrastruttura. Due database sincronizzati sono un’opzione per casi di scala specifica, non il punto di partenza.

CQRS non è “una classe per operazione”. Le Action Classes sono lo strumento con cui si realizza il lato scrittura, ma scrivere action senza separare il lato lettura non è CQRS: è solo codice ben organizzato, cosa ottima ma diversa.

I tre livelli, con il loro prezzo

Livello 1 — Separazione nel codiceLivello 2 — Read model dedicatiLivello 3 — Infrastruttura separata
ScritturaAction + aggregatoAction + aggregatoAction + aggregato, spesso con Event Sourcing
LetturaQuery object sulle stesse tabelleTabelle denormalizzate aggiornate da eventiDatabase o motore separato (ricerca, cache, replica)
CoerenzaImmediataImmediata o differita, a sceltaDifferita per costruzione
Costo aggiuntivoBasso: qualche classe in piùMedio: proiezioni da mantenere e ricostruireAlto: coerenza differita in tutta l’interfaccia
Quando convieneQuasi sempre, se c’è logica di dominioLetture pesanti, cruscotti, ricerche complesseScala elevata, requisiti di audit, team dedicato

Il resto dell’articolo lavora sul livello 1, con un passaggio sul livello 2, perché è lì che si colloca la stragrande maggioranza delle applicazioni Laravel che traggono beneficio dal pattern.

I quattro pezzi da orchestrare

Sparito il modello come centro, serve qualcosa che tenga insieme un’operazione di scrittura. Sono quattro componenti, ognuno con un compito che non si sovrappone agli altri.

Il Form Request: il confine HTTP

Valida la forma dell’input e nient’altro. È il punto dove il mondo esterno — inaffidabile per definizione — viene filtrato in dati di cui si conosce il tipo.

namespace App\Http\Requests;

class EmettiFatturaRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'ordine_id'          => ['required', 'ulid', 'exists:ordini,id'],
            'data_emissione'     => ['required', 'date', 'before_or_equal:today'],
            'modalita_pagamento' => ['required', Rule::enum(ModalitaPagamento::class)],
            'righe'              => ['required', 'array', 'min:1'],
            'righe.*.descrizione'=> ['required', 'string', 'max:255'],
            'righe.*.quantita'   => ['required', 'numeric', 'min:0.001'],
            'righe.*.prezzo'     => ['required', 'integer', 'min:0'],   // in centesimi
            'note'               => ['nullable', 'string', 'max:2000'],
        ];
    }

    /**
     * Qui NON stanno le regole di dominio.
     * "Un ordine già fatturato non si rifattura" è una regola dell'aggregato,
     * perché deve valere anche per la fatturazione automatica notturna.
     */
}

Il DTO: il comando come oggetto tipizzato

Il DTO — nel vocabolario CQRS, il comando — è l’intenzione espressa come dato. È immutabile, tipizzato, e non conosce né HTTP né il database.

namespace App\Fatturazione\Commands;

final readonly class EmettiFattura
{
    public function __construct(
        public OrdineId          $ordine,
        public DateTimeImmutable $dataEmissione,
        public ModalitaPagamento $modalita,
        /** @var list<RigaFatturaDto> */
        public array             $righe,
        public UtenteId          $emessaDa,
        public ?string           $note = null,
        /** Identificativo del comando: serve all'idempotenza, vedi più avanti */
        public string            $commandId = '',
    ) {}

    /** Costruzione dal confine HTTP: l'unico metodo che conosce la Request */
    public static function daRequest(EmettiFatturaRequest $request): self
    {
        return new self(
            ordine:        OrdineId::da($request->validated('ordine_id')),
            dataEmissione: new DateTimeImmutable($request->validated('data_emissione')),
            modalita:      ModalitaPagamento::from($request->validated('modalita_pagamento')),
            righe:         array_map(
                               RigaFatturaDto::daArray(...),
                               $request->validated('righe'),
                           ),
            emessaDa:      UtenteId::da($request->user()->id),
            note:          $request->validated('note'),
            commandId:     $request->header('Idempotency-Key') ?? (string) Str::ulid(),
        );
    }

    /** Costruzione da un contesto senza HTTP: comando Artisan, coda, webhook */
    public static function perFatturazioneAutomatica(Ordine $ordine): self
    {
        return new self(
            ordine:        $ordine->id(),
            dataEmissione: new DateTimeImmutable(),
            modalita:      $ordine->modalitaPagamentoPredefinita(),
            righe:         RigaFatturaDto::daRigheOrdine($ordine->righe()),
            emessaDa:      UtenteId::sistema(),
            commandId:     "auto-fatturazione:{$ordine->id()}",   // deterministico: vedi idempotenza
        );
    }
}

I due costruttori statici sono il dettaglio che rende il pattern utile: la stessa operazione è raggiungibile da HTTP e da un processo automatico, e l’Action non vede differenza fra i due. Nel flusso MVC classico, dove il controller orchestra, quella seconda via richiede di duplicare la logica in un comando Artisan.

Per i DTO si può scrivere tutto a mano, come sopra, oppure usare un pacchetto dedicato — spatie/laravel-data è il più diffuso nell’ecosistema e gestisce costruzione dalla request, validazione, cast e serializzazione in un’unica classe. Il compromesso è il solito: meno codice da scrivere, in cambio di un DTO che conosce il framework. Su progetti dove il dominio è il valore, preferisco le classi semplici; su applicazioni con molte form e poca logica, il pacchetto fa risparmiare molto tempo.

L’Action: l’operazione, atomica

È il cuore del lato scrittura: riceve il comando, carica gli aggregati, applica le regole, persiste, e rilascia gli eventi. Una classe per operazione, un solo metodo pubblico.

Gli eventi: ciò che succede dopo

Gli eventi di dominio raccontano un fatto avvenuto — fattura emessa, non emetti fattura — e sono il meccanismo con cui tutto il resto reagisce senza che l’Action debba conoscerlo: la notifica al cliente, l’aggiornamento del read model, l’invio allo Sdi, la riga nel registro di audit.

Il lato scrittura: un’operazione atomica dall’inizio alla fine

Mettiamo insieme i quattro pezzi. L’ordine delle operazioni dentro l’Action non è arbitrario: ogni riga sta dove sta per un motivo.

namespace App\Fatturazione\Actions;

use App\Fatturazione\Commands\EmettiFattura as ComandoEmettiFattura;
use Illuminate\Database\DatabaseManager;
use Illuminate\Contracts\Events\Dispatcher;

final readonly class EmettiFattura
{
    public function __construct(
        private OrdineRepository   $ordini,
        private FatturaRepository  $fatture,
        private NumeratoreFatture  $numeratore,
        private DatabaseManager    $db,
        private Dispatcher         $eventi,
    ) {}

    /**
     * @throws OrdineGiaFatturato
     * @throws OrdineNonFatturabile
     */
    public function esegui(ComandoEmettiFattura $comando): Fattura
    {
        // 1) TUTTA la scrittura in un'unica transazione
        $fattura = $this->db->transaction(function () use ($comando) {

            // 2) Carico l'aggregato con lock: due richieste contemporanee
            //    sullo stesso ordine non devono produrre due fatture
            $ordine = $this->ordini->perIdConLock($comando->ordine);

            // 3) Le regole di dominio stanno nell'aggregato, non qui
            $ordine->verificaFatturabilita();

            // 4) Il numero di fattura è progressivo e non può avere buchi:
            //    dentro la transazione, con il suo lock
            $numero = $this->numeratore->prossimo(
                anno: (int) $comando->dataEmissione->format('Y')
            );

            // 5) Creo l'aggregato nuovo: il costruttore applica le invarianti
            $fattura = Fattura::emetti(
                numero:        $numero,
                ordine:        $ordine,
                dataEmissione: $comando->dataEmissione,
                modalita:      $comando->modalita,
                righe:         $comando->righe,
                emessaDa:      $comando->emessaDa,
            );

            // 6) Due aggregati modificati nella stessa unità di lavoro
            $this->fatture->salva($fattura);
            $ordine->segnaComeFatturato($fattura->numero());
            $this->ordini->salva($ordine);

            return $fattura;
        });

        // 7) Gli eventi FUORI dalla transazione: a commit avvenuto,
        //    quindi chi reagisce trova dati che esistono davvero
        foreach ($fattura->eventiRilasciati() as $evento) {
            $this->eventi->dispatch($evento);
        }

        return $fattura;
    }
}

Quattro scelte in questo codice valgono più del resto, e sono quelle che distinguono un’operazione atomica da una che funziona nei test e sbaglia in produzione.

Il lock sull’aggregato (punto 2). Senza lockForUpdate, due richieste simultanee sullo stesso ordine superano entrambe il controllo “non ancora fatturato” e creano due fatture. È una corsa critica che in locale non si riproduce mai e in produzione si verifica al primo doppio clic dell’utente.

// FatturaRepositoryEloquent
public function perIdConLock(OrdineId $id): Ordine
{
    $record = OrdineRecord::query()
        ->whereKey((string) $id)
        ->lockForUpdate()        // il lock vive solo dentro la transazione
        ->firstOrFail();

    return $this->idrata($record);
}

Il numeratore dentro la transazione (punto 4). La numerazione delle fatture non ammette buchi né duplicati: è un requisito legale, non una preferenza. Deve quindi stare nello stesso confine transazionale della scrittura, altrimenti un rollback successivo brucia un numero.

Due aggregati, una transazione (punto 6). Qui si vede perché il confine transazionale appartiene all’Action e non all’aggregato: solo chi coordina sa che fattura emessa e ordine marcato come fatturato devono riuscire o fallire insieme.

Gli eventi dopo il commit (punto 7). È la regola più violata e quella che produce i bug più difficili da capire. Un evento pubblicato dentro la transazione può far partire un listener che legge una fattura non ancora scritta, o — peggio — inviare un’email per un’operazione che subito dopo va in rollback. L’email, quella, non si annulla.

L’aggregato che raccoglie i propri eventi

Il meccanismo del punto 7 richiede che l’aggregato accumuli gli eventi invece di pubblicarli. È un trait di una dozzina di righe e risolve il problema alla radice.

trait RilasciaEventiDiDominio
{
    /** @var list<object> */
    private array $eventiInSospeso = [];

    protected function registraEvento(object $evento): void
    {
        $this->eventiInSospeso[] = $evento;
    }

    /** @return list<object> */
    public function eventiRilasciati(): array
    {
        $eventi = $this->eventiInSospeso;
        $this->eventiInSospeso = [];      // svuota: non si pubblica due volte

        return $eventi;
    }
}
final class Fattura
{
    use RilasciaEventiDiDominio;

    public static function emetti(/* ... */): self
    {
        $fattura = new self(/* ... */);

        if ($fattura->totale()->zero()) {
            throw new FatturaConImportoNullo($numero);
        }

        $fattura->registraEvento(new FatturaEmessa(
            numero:        $fattura->numero,
            ordine:        $ordine->id(),
            totale:        $fattura->totale(),
            dataEmissione: $dataEmissione,
        ));

        return $fattura;
    }
}

L’alternativa nativa, se si preferisce non scrivere il trait, è DB::afterCommit(), che registra una callback eseguita al commit della transazione corrente — e immediatamente se non c’è transazione aperta. Funziona bene, con un solo accorgimento: se la pubblicazione avviene lì, non c’è un punto unico in cui si vede l’elenco degli eventi di un’operazione, e il flusso diventa più difficile da seguire leggendo il codice.

Il lato lettura: query object e read model

L’altra metà del pattern, quella che nel flusso MVC non esisteva come entità separata. Il principio è semplice: per leggere non si passa dagli aggregati. Caricare una fattura intera, con le sue righe e le sue regole, per poi mostrarne numero, data e totale in un elenco è lavoro sprecato — e su cinquanta righe per pagina diventa un problema misurabile.

namespace App\Fatturazione\Queries;

/** Oggetto di lettura: piatto, immutabile, costruito per UNA schermata */
final readonly class RigaElencoFatture
{
    public function __construct(
        public string $numero,
        public string $dataEmissione,
        public string $cliente,
        public int    $totaleCentesimi,
        public string $stato,
        public bool   $scaduta,
        public ?string $numeroOrdine,
    ) {}
}

final class ElencoFattureQuery
{
    public function __construct(private ConnectionInterface $db) {}

    /** @return LengthAwarePaginator<RigaElencoFatture> */
    public function esegui(FiltroFatture $filtro, int $perPagina = 25): LengthAwarePaginator
    {
        return $this->db->table('fatture as f')
            ->join('clienti as c', 'c.id', '=', 'f.cliente_id')
            ->leftJoin('ordini as o', 'o.id', '=', 'f.ordine_id')
            ->select([
                'f.numero', 'f.data_emissione', 'f.totale', 'f.stato',
                'f.data_scadenza', 'c.ragione_sociale', 'o.numero as numero_ordine',
            ])
            ->when($filtro->anno,    fn ($q, $anno) => $q->whereYear('f.data_emissione', $anno))
            ->when($filtro->cliente, fn ($q, $id)   => $q->where('f.cliente_id', $id))
            ->when($filtro->soloScadute, fn ($q) => $q
                ->where('f.stato', StatoFattura::NonPagata->value)
                ->where('f.data_scadenza', '<', now()))
            ->orderByDesc('f.data_emissione')
            ->paginate($perPagina)
            ->through(fn ($r) => new RigaElencoFatture(
                numero:          $r->numero,
                dataEmissione:   $r->data_emissione,
                cliente:         $r->ragione_sociale,
                totaleCentesimi: (int) $r->totale,
                stato:           $r->stato,
                scaduta:         $r->stato === 'non_pagata' && $r->data_scadenza < now(),
                numeroOrdine:    $r->numero_ordine,
            ));
    }
}

Questa classe usa il query builder senza mediazioni, e va bene: non fa parte del dominio e il suo contratto sono i DTO che restituisce. È anche il motivo per cui il lato lettura del CQRS è spesso più semplice del codice MVC che sostituisce, non più complesso.

Il livello 2: proiezioni aggiornate dagli eventi

Quando le letture diventano pesanti — un cruscotto che aggrega su milioni di righe, una ricerca con molti filtri — si passa a una tabella denormalizzata, costruita per essere letta e aggiornata dagli eventi del lato scrittura.

// La proiezione: una riga per fattura, già unita e calcolata
Schema::create('proiezione_fatture', function (Blueprint $table) {
    $table->ulid('fattura_id')->primary();
    $table->string('numero')->index();
    $table->date('data_emissione')->index();
    $table->string('cliente_ragione_sociale');       // denormalizzato
    $table->string('cliente_partita_iva')->nullable();
    $table->unsignedBigInteger('totale');
    $table->string('stato')->index();
    $table->date('data_scadenza')->nullable()->index();
    $table->unsignedInteger('giorni_ritardo')->default(0);
    $table->timestamp('aggiornata_il');
});
final class ProiezioneFattureListener implements ShouldQueue
{
    public bool $afterCommit = true;   // non parte prima del commit della scrittura

    public function handle(FatturaEmessa|FatturaPagata|FatturaAnnullata $evento): void
    {
        // Idempotente: lo stesso evento riprocessato produce lo stesso risultato
        DB::table('proiezione_fatture')->upsert(
            $this->righeDa($evento),
            uniqueBy: ['fattura_id'],
        );
    }
}
// La ricostruzione: indispensabile, e va scritta il giorno in cui nasce la proiezione
class RicostruisciProiezioneFatture extends Command
{
    protected $signature = 'proiezioni:fatture {--da=}';

    public function handle(): int
    {
        DB::table('proiezione_fatture')->truncate();

        Fattura::query()->with(['cliente', 'ordine'])
            ->when($this->option('da'), fn ($q, $da) => $q->where('data_emissione', '>=', $da))
            ->chunkById(500, fn ($blocco) => DB::table('proiezione_fatture')
                ->insert($blocco->map($this->righeDa(...))->all()));

        return self::SUCCESS;
    }
}

Il comando di ricostruzione non è un extra: è il requisito che rende le proiezioni sostenibili. Prima o poi la logica di proiezione cambierà, o un listener fallirà silenziosamente, e l’unica risposta sarà ricostruire. Una proiezione senza ricostruzione è un dato che si corrompe in silenzio.

La trappola della coerenza differita

Questo è il problema che fa abbandonare il livello 2 a metà, e vale la pena conoscerlo prima di incontrarlo. Scenario: l’utente emette una fattura, il browser viene rediretto all’elenco, e la fattura appena creata non c’è. Il listener di proiezione è in coda e non è ancora stato eseguito. Tecnicamente tutto corretto, per l’utente è un bug — e segnalato come tale.

Tre soluzioni, in ordine di preferenza pratica. La prima: dopo una scrittura, leggere dal modello di scrittura, non dalla proiezione — la pagina di dettaglio successiva al salvataggio interroga le tabelle vere, dove il dato c’è per certo. La seconda: aggiornare la proiezione in modo sincrono per gli eventi in cui l’immediatezza conta, lasciando in coda solo il resto. La terza, da usare con parsimonia: comunicare l’attesa nell’interfaccia, che è accettabile su operazioni lunghe e dichiarate, molto meno su un salvataggio.

La regola che evita il problema nel 90% dei casi: le proiezioni servono agli elenchi e ai cruscotti, non alla pagina che l’utente vede subito dopo aver premuto Salva.

Il Command Bus e i suoi middleware

Con il flusso MVC smontato, resta una domanda: chi collega il comando all’Action che lo esegue? Si può fare a mano — il controller inietta l’Action e la chiama, come negli esempi fin qui — e per molti progetti è la scelta giusta. Ma quando le operazioni diventano decine e tutte hanno bisogno degli stessi contorni — transazione, autorizzazione, audit, idempotenza — conviene un command bus: un dispatcher che riceve il comando, individua chi lo gestisce e lo fa passare attraverso una catena di middleware.

La meccanica è esattamente quella della Pipeline del framework: una pila di Closure costruita con array_reduce, dove ogni stadio può agire prima e dopo l’esecuzione. Un bus concettuale sta in venti righe.

namespace App\Shared\Bus;

use Illuminate\Pipeline\Pipeline;

final readonly class CommandBus
{
    /** @param list<class-string> $middleware */
    public function __construct(
        private Pipeline $pipeline,
        private HandlerResolver $resolver,
        private array $middleware,
    ) {}

    public function dispatch(object $comando): mixed
    {
        return $this->pipeline
            ->send($comando)
            ->through($this->middleware)
            ->then(fn (object $comando) => $this->resolver->per($comando)->esegui($comando));
    }
}
// Registrazione: l'ordine dei middleware È la semantica
$this->app->singleton(CommandBus::class, fn ($app) => new CommandBus(
    $app->make(Pipeline::class),
    $app->make(HandlerResolver::class),
    [
        RegistraComandoMiddleware::class,     // per primo: vede tutto, anche i fallimenti
        AutorizzaComandoMiddleware::class,    // prima di qualsiasi scrittura
        ValidaComandoMiddleware::class,
        IdempotenzaMiddleware::class,         // prima della transazione
        TransazioneMiddleware::class,         // apre il confine atomico
        PubblicaEventiMiddleware::class,      // DENTRO: raccoglie, pubblica al commit
    ],
));

Vediamo i middleware uno per uno, perché ognuno risolve un problema concreto che altrimenti va ripetuto in ogni Action.

Middleware 1 — La transazione

Il più importante: sposta il confine atomico dall’Action all’infrastruttura, così che nessuna operazione possa dimenticarselo.

final readonly class TransazioneMiddleware
{
    public function __construct(private DatabaseManager $db) {}

    public function handle(object $comando, Closure $next): mixed
    {
        // Un comando può dichiarare di non volere la transazione
        // (tipico delle importazioni massive, che gestiscono il proprio confine)
        if ($comando instanceof SenzaTransazione) {
            return $next($comando);
        }

        return $this->db->transaction(fn () => $next($comando));
    }
}

Middleware 2 — L’autorizzazione

Sposta il controllo dei permessi dal controller al bus, in modo che valga anche per i comandi che arrivano da API, webhook o interfacce diverse.

final readonly class AutorizzaComandoMiddleware
{
    public function __construct(private Gate $gate, private AuthManager $auth) {}

    public function handle(object $comando, Closure $next): mixed
    {
        // Solo i comandi che dichiarano una policy vengono verificati:
        // i comandi di sistema (fatturazione notturna) non hanno utente
        if ($comando instanceof RichiedeAutorizzazione && $this->auth->check()) {
            $this->gate->authorize($comando->abilita(), $comando->soggetto());
        }

        return $next($comando);
    }
}

Middleware 3 — La validazione del comando

Il Form Request copre il confine HTTP. Un comando costruito da un job o da un comando Artisan non passa da lì: questo middleware garantisce che le invarianti del DTO valgano su ogni percorso.

final readonly class ValidaComandoMiddleware
{
    public function __construct(private ValidationFactory $validatore) {}

    public function handle(object $comando, Closure $next): mixed
    {
        if ($comando instanceof Validabile) {
            $this->validatore
                ->make($comando->perValidazione(), $comando->regole())
                ->validate();   // lancia ValidationException, uguale al Form Request
        }

        return $next($comando);
    }
}

Middleware 4 — L’idempotenza

Risolve il doppio clic, il retry di un job e il webhook ricevuto due volte. Il comando porta un identificativo; se quell’identificativo è già stato eseguito, si restituisce l’esito precedente senza rieseguire nulla.

final readonly class IdempotenzaMiddleware
{
    public function __construct(private CacheRepository $cache) {}

    public function handle(object $comando, Closure $next): mixed
    {
        if (! $comando instanceof Idempotente || $comando->commandId === '') {
            return $next($comando);
        }

        $chiave = 'cmd:' . class_basename($comando) . ':' . $comando->commandId;

        // Già eseguito: restituisco l'esito memorizzato, niente scrittura
        if ($this->cache->has($chiave)) {
            return $this->cache->get($chiave);
        }

        // Lock: due richieste contemporanee con la stessa chiave non si sovrappongono
        return $this->cache->lock($chiave . ':lock', 10)->block(5, function () use ($chiave, $comando, $next) {
            if ($this->cache->has($chiave)) {
                return $this->cache->get($chiave);
            }

            $esito = $next($comando);
            $this->cache->put($chiave, $esito, now()->addHours(24));

            return $esito;
        });
    }
}

Il dettaglio che rende questo middleware efficace è il commandId deterministico visto nel DTO: "auto-fatturazione:{$ordine->id()}". Se il job notturno viene riprovato, l’identificativo è lo stesso e la seconda esecuzione non fa niente — che è esattamente ciò che si vuole da un processo di fatturazione.

Middleware 5 — La pubblicazione degli eventi al commit

Garantisce la regola del punto 7 a livello di infrastruttura: gli eventi raccolti durante l’operazione vengono pubblicati solo quando la transazione ha fatto commit.

final readonly class PubblicaEventiMiddleware
{
    public function __construct(
        private RaccoglitoreEventi $raccoglitore,
        private Dispatcher $eventi,
        private DatabaseManager $db,
    ) {}

    public function handle(object $comando, Closure $next): mixed
    {
        $this->raccoglitore->inizia();

        try {
            $esito = $next($comando);

            // Registrata DENTRO la transazione, eseguita al commit.
            // Se si va in rollback, gli eventi non vengono mai pubblicati.
            $this->db->afterCommit(function () {
                foreach ($this->raccoglitore->svuota() as $evento) {
                    $this->eventi->dispatch($evento);
                }
            });

            return $esito;
        } catch (Throwable $e) {
            $this->raccoglitore->scarta();   // niente eventi su operazione fallita
            throw $e;
        }
    }
}

Middleware 6 — Il registro delle operazioni

Un audit trail completo in una classe, al posto di righe di log sparse in ogni Action. Posto per primo, intercetta anche i comandi che falliscono — che sono spesso i più interessanti da avere nel registro.

final readonly class RegistraComandoMiddleware
{
    public function __construct(private AuthManager $auth) {}

    public function handle(object $comando, Closure $next): mixed
    {
        $inizio = hrtime(true);
        $contesto = [
            'comando'    => class_basename($comando),
            'command_id' => $comando->commandId ?? null,
            'utente_id'  => $this->auth->id(),
            'request_id' => request()?->header('X-Request-Id'),
        ];

        try {
            $esito = $next($comando);

            Log::channel('comandi')->info('comando eseguito', $contesto + [
                'durata_ms' => round((hrtime(true) - $inizio) / 1_000_000, 2),
            ]);

            return $esito;
        } catch (Throwable $e) {
            Log::channel('comandi')->warning('comando fallito', $contesto + [
                'eccezione' => $e::class,
                'messaggio' => $e->getMessage(),
            ]);

            throw $e;
        }
    }
}

Middleware 7 — Il lock sull’aggregato

Alternativa dichiarativa al lockForUpdate scritto dentro l’Action: il comando dichiara su cosa opera, e il middleware serializza le operazioni concorrenti sullo stesso aggregato.

final readonly class LockAggregatoMiddleware
{
    public function __construct(private CacheRepository $cache) {}

    public function handle(object $comando, Closure $next): mixed
    {
        if (! $comando instanceof OperaSuAggregato) {
            return $next($comando);
        }

        $lock = $this->cache->lock("aggregato:{$comando->aggregatoId()}", 15);

        // Se un'altra operazione è in corso sullo stesso aggregato, attendo;
        // se non si libera entro 5 secondi, fallisco in modo pulito
        return $lock->block(5, fn () => $next($comando));
    }
}

Una nota sull’ordine

Come in qualsiasi pipeline, l’ordine non è una preferenza ma una dipendenza. Tre vincoli sono rigidi: il registro va per primo, altrimenti non vede i comandi respinti dall’autorizzazione; l’idempotenza va prima della transazione, perché deve poter restituire l’esito memorizzato senza aprirne una; la pubblicazione degli eventi va dentro la transazione, perché la sua callback deve agganciarsi al commit di quella transazione specifica.

Chi preferisce non scrivere il bus a mano può usare quello nativo: il dispatcher dei comandi di Laravel accetta una pipeline di middleware tramite Bus::pipeThrough(), con la stessa semantica degli stadi visti qui.

Come cambiano controller e struttura del progetto

Con il bus in piedi, il controller si riduce a ciò che è rimasto del suo ruolo originario.

final class EmettiFatturaController
{
    public function __invoke(EmettiFatturaRequest $request, CommandBus $bus): JsonResponse
    {
        $fattura = $bus->dispatch(ComandoEmettiFattura::daRequest($request));

        return FatturaResource::make($fattura)
            ->response()
            ->setStatusCode(201)
            ->header('Location', route('fatture.show', $fattura->numero()));
    }
}
// Il lato lettura non passa dal bus: non c'è niente da coordinare
final class ElencoFattureController
{
    public function __invoke(Request $request, ElencoFattureQuery $query): View
    {
        return view('fatture.index', [
            'fatture' => $query->esegui(FiltroFatture::daRequest($request)),
        ]);
    }
}

Tre righe per la scrittura, tre per la lettura, e la differenza di percorso è visibile a occhio. La struttura delle cartelle che ne consegue separa le due metà in modo esplicito:

app/
  Fatturazione/
    Commands/        EmettiFattura.php, AnnullaFattura.php        ← i DTO
    Actions/         EmettiFattura.php, AnnullaFattura.php        ← gli handler
    Queries/         ElencoFattureQuery.php, DettaglioFatturaQuery.php
                     RigaElencoFatture.php, FiltroFatture.php     ← DTO di lettura
    Domain/          Fattura.php, Numeratore.php, StatoFattura.php
    Events/          FatturaEmessa.php, FatturaPagata.php
    Listeners/       ProiezioneFattureListener.php, InviaFatturaSdiListener.php
    Infrastructure/  FatturaRepositoryEloquent.php
  Shared/
    Bus/             CommandBus.php, HandlerResolver.php
                     Middleware/ (i sette visti sopra)

Una nota su una fonte ricorrente di confusione: Commands/EmettiFattura e Actions/EmettiFattura hanno lo stesso nome e sono cose diverse — il primo è l’intenzione come dato, il secondo è l’esecuzione. Chi trova la coincidenza fastidiosa può usare il suffisso Command sul DTO; l’importante è che la convenzione sia una sola in tutto il progetto.

Testare le due metà separatamente

Il beneficio concreto della separazione si vede nei test, perché le due metà hanno bisogni opposti.

Il lato scrittura si collauda sulle regole e sull’atomicità. Le regole senza database, l’atomicità con il database — perché il rollback è precisamente ciò che si sta verificando.

it('non emette due fatture per lo stesso ordine', function () {
    $ordine = OrdineFactory::fatturabile()->create();
    $bus    = app(CommandBus::class);

    $bus->dispatch(ComandoEmettiFattura::perFatturazioneAutomatica($ordine->fresh()));

    expect(fn () => $bus->dispatch(
        ComandoEmettiFattura::perFatturazioneAutomatica($ordine->fresh())
    ))->toThrow(OrdineGiaFatturato::class);

    expect(Fattura::where('ordine_id', $ordine->id)->count())->toBe(1);
});

it('non lascia niente scritto se il numeratore fallisce', function () {
    $this->mock(NumeratoreFatture::class)
         ->shouldReceive('prossimo')
         ->andThrow(new NumerazioneBloccata());

    $ordine = OrdineFactory::fatturabile()->create();

    expect(fn () => app(CommandBus::class)->dispatch(
        ComandoEmettiFattura::perFatturazioneAutomatica($ordine)
    ))->toThrow(NumerazioneBloccata::class);

    // La transazione ha annullato tutto: nessuna fattura, ordine non marcato
    expect(Fattura::count())->toBe(0)
        ->and($ordine->fresh()->fatturato)->toBeFalse();
});

it('non pubblica eventi se la transazione va in rollback', function () {
    Event::fake();
    $this->mock(FatturaRepository::class)
         ->shouldReceive('salva')->andThrow(new RuntimeException('disco pieno'));

    $ordine = OrdineFactory::fatturabile()->create();

    expect(fn () => app(CommandBus::class)->dispatch(
        ComandoEmettiFattura::perFatturazioneAutomatica($ordine)
    ))->toThrow(RuntimeException::class);

    Event::assertNotDispatched(FatturaEmessa::class);
});

Il lato lettura si collauda sui dati restituiti, non sulle regole: che i filtri filtrino, che i campi calcolati siano corretti, che la paginazione funzioni. Sono test con il database, inevitabilmente, ma rapidi perché non costruiscono aggregati.

it('marca come scadute solo le non pagate oltre la scadenza', function () {
    FatturaFactory::nonPagata(scadenza: '2026-09-01')->create(['numero' => 'A/1']);
    FatturaFactory::nonPagata(scadenza: '2026-12-31')->create(['numero' => 'A/2']);
    FatturaFactory::pagata(scadenza: '2026-09-01')->create(['numero'   => 'A/3']);

    $righe = app(ElencoFattureQuery::class)
        ->esegui(new FiltroFatture())
        ->keyBy->numero;

    expect($righe['A/1']->scaduta)->toBeTrue()
        ->and($righe['A/2']->scaduta)->toBeFalse()
        ->and($righe['A/3']->scaduta)->toBeFalse();
});

E se c’è una proiezione, serve un terzo test — quello che quasi tutti dimenticano: la proiezione ricostruita da zero deve coincidere con quella costruita dagli eventi. È l’equivalente, per il livello 2, del test di riconciliazione.

Quando fermarsi: i tre livelli e il loro prezzo reale

Il CQRS ha un costo di comprensione che si paga una volta e un costo di manutenzione che si paga sempre. Tre criteri per decidere dove fermarsi.

Se l’applicazione è prevalentemente CRUD, non serve niente di tutto questo. Un modulo di gestione anagrafiche non ha comandi né regole: il flusso MVC con Form Request ed Eloquent è la scelta corretta, e il CQRS aggiungerebbe quattro classi per operazioni che ne richiedono zero. Le due metà si separano dove c’è una scrittura con regole e una lettura con esigenze diverse — non per uniformità architetturale.

Il livello 1 si ripaga quasi sempre, quando c’è dominio. Separare le Action dai Query object non introduce coerenza differita, non richiede proiezioni, non cambia l’infrastruttura. Costa qualche classe in più e restituisce test rapidi, scritture atomiche e letture veloci. È il punto in cui conviene fermarsi per la maggioranza dei progetti.

Il livello 2 va introdotto per una ragione misurata, non per principio. Una proiezione si giustifica quando una query è dimostrabilmente lenta e l’ottimizzazione normale non basta. Il prezzo è reale: un listener che può fallire, una ricostruzione da mantenere, la coerenza differita da gestire nell’interfaccia. Introdurre proiezioni “perché è CQRS fatto bene” significa comprare quei tre problemi senza il beneficio che li compensa.

Il livello 3 richiede un motivo esterno. Database separati, Event Sourcing, coerenza differita per costruzione: si adottano quando esiste un requisito che li impone — volumi che non stanno su un nodo, obblighi di tracciabilità completa delle modifiche, letture che devono reggere un ordine di grandezza in più delle scritture. Fuori da quei casi, il costo in complessità supera il beneficio con larghezza.

Sei errori ricorrenti

  1. Pubblicare gli eventi dentro la transazione. Il listener legge dati non ancora scritti, o invia un’email per un’operazione che va in rollback. Gli eventi si raccolgono durante e si pubblicano al commit.
  2. Non mettere il lock sull’aggregato. Il controllo “non ancora fatturato” senza lockForUpdate è una corsa critica che il doppio clic dell’utente scopre il primo giorno.
  3. Leggere dalla proiezione subito dopo la scrittura. La pagina successiva al Salva non trova il dato appena creato, e l’utente segnala un bug che tecnicamente non è un bug. Dopo una scrittura si legge dal modello di scrittura.
  4. Mettere le regole di dominio nel Form Request. Funziona via HTTP e lascia completamente scoperto il percorso dei comandi Artisan, dei job e dei webhook — dove la regola serve esattamente quanto nell’interfaccia.
  5. Far restituire al DTO un oggetto del framework. Un comando che contiene una Request, o che espone un modello Eloquent, riporta il framework dentro il dominio e vanifica la separazione dei percorsi.
  6. Creare proiezioni senza il comando di ricostruzione. Il giorno in cui la logica di proiezione cambia o un listener fallisce in silenzio, l’unica via d’uscita è ricostruire — e se non esiste, va scritta sotto pressione.

Checklist di un’operazione di scrittura ben orchestrata

  • Il Form Request valida solo la forma dell’input, non le regole di dominio.
  • Il DTO è immutabile, tipizzato, e non contiene oggetti del framework.
  • Il DTO ha un costruttore per HTTP e almeno uno per i contesti senza HTTP.
  • Le regole di dominio stanno nell’aggregato e valgono su ogni percorso d’ingresso.
  • Il confine transazionale è dichiarato in un punto solo: l’Action o il middleware.
  • Gli aggregati modificati insieme stanno nella stessa transazione.
  • Gli aggregati soggetti a concorrenza vengono caricati con un lock.
  • I progressivi senza buchi sono generati dentro la transazione.
  • Gli eventi si raccolgono durante l’operazione e si pubblicano dopo il commit.
  • Le chiamate a sistemi esterni stanno fuori dalla transazione.
  • Le operazioni ripetibili portano un identificativo e passano da un controllo di idempotenza.
  • Il lato lettura non carica aggregati: usa query dedicate che restituiscono DTO.
  • Se esiste una proiezione, esiste anche il comando per ricostruirla, e un test che la confronta.
  • La pagina mostrata subito dopo una scrittura legge dal modello di scrittura.

Conclusioni

Il CQRS rompe il flusso MVC in un punto preciso: toglie al modello il ruolo di centro di gravità. Da quel momento scrivere e leggere non sono più due usi dello stesso oggetto ma due percorsi indipendenti, con componenti propri — Form Request e DTO al confine, Action e aggregati per la scrittura, query object e DTO piatti per la lettura, eventi come collegamento fra le due metà.

Il lavoro che prima faceva il controller non sparisce: si distribuisce. E la parte che richiede più attenzione non è nessuno dei singoli pezzi, che sono tutti semplici, ma la loro orchestrazione atomica: una transazione sola per operazione, i lock dove c’è concorrenza, i progressivi dentro il confine, gli eventi fuori, le chiamate esterne mai dentro. Sono cinque regole, e sbagliarne una produce i bug che si manifestano solo sotto carico — doppie fatture, email per operazioni annullate, numerazioni con buchi.

Il consiglio operativo per iniziare è di non convertire un’applicazione intera. Si prende l’operazione di scrittura più delicata — quella con il progressivo, o quella che non deve mai avvenire due volte — e la si porta sul percorso completo: Form Request, DTO con i suoi due costruttori, Action con una transazione sola, eventi dopo il commit. Poi si prende l’elenco più lento e lo si sposta su un query object che restituisce DTO. Due interventi, due metà separate, e il resto dell’applicazione intatta: a quel punto si ha un metro di paragone concreto per decidere se estendere, e — altrettanto utile — se fermarsi lì.