Smetti di salvare stati che puoi calcolare

Enum PHP, status e business logic: perché salvare nel database uno stato che puoi già derivare dai dati rischia di creare una seconda fonte di verità.

8 min di letturaPHP

Luigi Iadicola, Programmatore - Napoli

In questo articolo

status = "paid".

Perfetto. L'ordine è pagato.

Possiamo andare a casa.

Poi qualcuno apre il database:

orders
id     = 42
status = "paid"

payments
(nessun record per order_id = 42)

Ah.

Quindi per orders.status è pagato, ma nella tabella che dovrebbe contenere il fatto economico non esiste alcun pagamento.

Schrödinger sarebbe orgoglioso del nostro gestionale.

L'ordine è contemporaneamente pagato e non pagato, dipende da quale tabella decidiamo di credere.

Il problema non è PHP e non sono nemmeno gli enum. Il problema è aver creato due rappresentazioni della stessa realtà.

Spoiler: prima o poi non rimarranno sincronizzate.

Il classico campo status

È una soluzione che utilizziamo continuamente nello sviluppo software.

Abbiamo un ordine e definiamo un enum PHP:

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Completed = 'completed';
    case Cancelled = 'cancelled';
}

Poi aggiungiamo orders.status e lo aggiorniamo durante il ciclo di vita dell'ordine.

Pulito. Leggibile. Professionale. Ci sentiamo quasi architetti software.

Poi il progetto cresce. Arrivano una tabella payments, una tabella shipments, webhook, job, listener e probabilmente quel service che nessuno vuole più aprire.

A quel punto status rischia di diventare una copia sintetica di fatti che il dominio possiede già.

Due fonti di verità: cosa potrebbe mai andare storto?

Immaginiamo invece:

orders
id     = 42
status = "pending"

payments
id         = 184
order_id   = 42
created_at = "2026-09-23 10:30:00"

Il record di pagamento esiste ed è associato all'ordine. Nel modello che stiamo ipotizzando, un record in payments viene creato solo quando il pagamento è effettivamente riuscito.

Eppure orders.status sostiene fieramente: pending.

Qual è la fonte di verità?

Possiamo aggiungere altro codice per sincronizzare il codice che sincronizza altro codice. Oppure possiamo farci una domanda più semplice:

Se ho già il pagamento, perché devo anche salvare che l'ordine è pagato?

In questo modello il fatto di business non è orders.paid_at e nemmeno orders.status = paid: è l'esistenza del record nella tabella payments.

Non ho bisogno di salvare che è pagato. Ho già il pagamento.

Questa distinzione cambia parecchio il modo in cui modelliamo il dominio.

Invece di dire:

L'ordine è pagato perché status = paid.

possiamo dire:

L'ordine è pagato perché esiste un pagamento valido associato all'ordine.

La business logic potrebbe quindi derivare lo stato dalla relazione:

public function status(): OrderStatus
{
    if ($this->payment()->exists()) {
        return OrderStatus::Paid;
    }

    return OrderStatus::Pending;
}

In un'implementazione reale naturalmente eviteremmo di generare query inutili ogni volta che accediamo allo stato: potremmo lavorare con relazioni già caricate, query ottimizzate, proiezioni o altre strategie appropriate al caso.

Ma il punto architetturale rimane: la presenza del pagamento determina lo stato; non è lo stato a certificare l'esistenza del pagamento.

Ma attenzione a created_at: non è automaticamente la data del pagamento

Qui c'è una precisazione importante.

Possiamo usare payments.created_at come momento del pagamento solo se il contratto del nostro dominio stabilisce che il record viene creato esclusivamente dopo un pagamento riuscito.

Se invece il record payments può nascere prima — per esempio quando il pagamento viene iniziato, autorizzato, messo in attesa oppure prima della conferma definitiva del provider — allora created_at ci dice soltanto quando abbiamo creato il record nel nostro database.

Non necessariamente quando il denaro è stato effettivamente incassato.

In un sistema del genere potrebbe essere più corretto avere un fatto esplicito proveniente dal provider, una transazione collegata, un evento di cattura o un campo come captured_at/succeeded_at.

Ed è proprio questa la parte interessante: non dobbiamo eliminare i dati importanti in nome della purezza architetturale. Dobbiamo capire quale dato rappresenta davvero il fatto di business.

Se payments.created_at coincide semanticamente con “pagamento avvenuto”, è sufficiente. Se significa soltanto “record creato”, non facciamogli fare un lavoro per cui non è stato assunto.

Lo stato può essere calcolato dalla business logic

Il principio quindi non è “usa sempre una data” e nemmeno “usa sempre una relazione”.

Il principio è: deriva lo stato dal fatto più concreto che possiedi nel dominio.

Per un ordine potrebbe essere un pagamento riuscito, una spedizione realmente creata, una consegna registrata o una cancellazione accettata.

L'enum continua a essere utile per esprimere il risultato:

OrderStatus::Paid

Quello che non deve necessariamente esistere è una seconda informazione persistita che dica la stessa cosa.

Enum e stato non sono la stessa cosa

Un enum PHP può rappresentare perfettamente OrderStatus::Paid, ma questo non implica automaticamente che debba esistere orders.status = "paid".

L'enum definisce il vocabolario del dominio. La business logic determina quale valore di quel vocabolario rappresenta la situazione corrente.

L'enum può quindi essere l'output del calcolo, senza diventare per forza una colonna persistita.

Salva i fatti, non le conclusioni

Un record di pagamento riuscito è un fatto. Una spedizione creata è un fatto. Una consegna avvenuta è un fatto. Una cancellazione accettata è un fatto.

Paid, Shipped e Completed possono essere interpretazioni di quei fatti.

Se possiamo ricostruire lo stato partendo dai fatti, abbiamo una source of truth concreta.

E soprattutto evitiamo di dover ricordare di aggiornare status nel service giusto, nel listener giusto, nel job giusto e nel webhook aggiunto venerdì alle 18:47 prima di spegnere il portatile.

Una fattura scaduta ha davvero bisogno di un cron?

Lo stesso ragionamento vale per una fattura. Cosa significa realmente Overdue? In un modello semplice significa che non esiste un pagamento valido e che due_date è nel passato.

public function isOverdue(): bool
{
    return ! $this->payments()->successful()->exists()
        && $this->due_date->isPast();
}

Ed ecco la magia più rivoluzionaria dell'informatica: il tempo passa anche senza un cron job.

La fattura non ha bisogno di un processo che a mezzanotte le comunichi educatamente che da quel momento è ufficialmente scaduta. Se lo stato è derivabile dai fatti correnti, il dominio lo sa già.

Gli stati derivati eliminano transizioni inutili

Un abbonamento può essere scaduto quando expires_at < now(). Un prodotto può essere esaurito quando available_quantity = 0. Un ordine può essere pagato quando esiste un pagamento riuscito.

Non tutte queste condizioni richiedono una transizione persistita.

A volte lo stato è semplicemente una funzione dei dati correnti.

Attenzione: non trasformiamo tutto in una religione

A questo punto qualcuno potrebbe voler eliminare tutti i campi status dal database.

Possiamo posare la migration.

Il principio non è “non salvare mai uno stato”. È:

Non salvare come fonte di verità uno stato che è già completamente determinato da altre fonti di verità.

Una pratica draft / under_review / approved / rejected, per esempio, può rappresentare una decisione esplicita presa da una persona autorizzata. L'approvazione è essa stessa un fatto di business.

In quel caso persistere lo stato può essere corretto. Lo stesso vale per una vera state machine, dove le transizioni hanno significato proprio e producono conseguenze nel dominio.

La domanda da fare prima di aggiungere status

Prima di creare l'ennesimo status VARCHAR(255), chiediamoci:

Questo stato rappresenta un'informazione indipendente oppure è soltanto il risultato di dati che possiedo già?

Se possiamo determinarlo con certezza dai dati esistenti, probabilmente stiamo duplicando informazione.

E ogni informazione duplicata porta con sé una domanda: chi la tiene sincronizzata?

Per ogni percorso applicativo. Per ogni import. Per ogni webhook. Per ogni script scritto “solo per questa volta”.

E sappiamo tutti che “solo per questa volta” è una delle principali strategie di persistenza del software enterprise.

E le performance?

Naturalmente calcolare uno stato può essere costoso. Se per determinarlo dobbiamo eseguire join, aggregazioni o query complesse su milioni di record, può avere senso materializzare uno stato derivato.

Ma quel valore non diventa improvvisamente la realtà. È una proiezione della realtà, mantenuta per ragioni di performance.

La distinzione è importante: se la proiezione diventa inconsistente, possiamo ricostruirla partendo dai fatti originali.

Source of truth e business logic

Il principio che cerco di seguire è semplice:

Salva i fatti. Calcola ciò che puoi dedurre dai fatti.

Un pagamento riuscito è un fatto. Una consegna avvenuta è un fatto. Una cancellazione accettata è un fatto. Una decisione di approvazione è un fatto.

Lo stato derivato è spesso semplicemente il modo in cui la business logic interpreta questi fatti in un determinato momento.

Così evitiamo situazioni in cui una tabella sostiene che l'ordine sia completato, quella dei pagamenti dice che nessuno ha mai pagato e quella delle spedizioni sembra non aver mai conosciuto quell'ordine.

A quel punto non hai più uno stato. Hai un sondaggio.

Gli enum non sono il problema

Quindi no: questo non è un articolo contro gli enum PHP.

Gli enum sono estremamente utili per rappresentare insiemi finiti di valori, migliorare la leggibilità e rendere il codice più type-safe.

Il problema nasce quando confondiamo la rappresentazione dello stato con la fonte che determina quello stato.

Possiamo usare tranquillamente OrderStatus::Paid senza essere obbligati a memorizzare "paid" nel database.

Prima di aggiungere un campo status, chiediamoci:

Qual è la vera fonte di verità che mi permette di sapere che questa entità si trova in questo stato?

Se la risposta è “esiste già un fatto nel dominio che me lo dimostra”, probabilmente non abbiamo bisogno di salvare un'altra verità.

Ne abbiamo già una.

E soprattutto, una è molto più facile da tenere sincronizzata di due.

Hai lo stesso problema in azienda?

Raccontami cosa non va e da quando. Guardo la situazione con te prima di proporre qualsiasi intervento.

RaccontameloRispondo entro un giorno lavorativo.
Condividi:LinkedIn

Ricevi il prossimo articolo

Una mail per ogni articolo nuovo, nient'altro. Ti disiscrivi con un clic dal link in fondo a ogni email.