← Torna agli articoli
Architettura PHP e pattern di progettazione

Value Objects in PHP: smetti di scrivere codice ossessionato dalle primitive

Scopri come i Value Objects eliminano l'ossessione per le primitive in PHP, con esempi reali di classi Email e Money che portano sicurezza dei tipi, validazione e chiarezza nel tuo codice di dominio.

Value Objects in PHP: smetti di scrivere codice ossessionato dalle primitive
Articolo in evidenza ↗

Value Objects in PHP: smetti di scrivere codice ossessionato dalle primitive

Guarda quasi qualsiasi codebase PHP e troverai lo stesso schema: email memorizzate come stringhe, denaro memorizzato come float, ID memorizzati come interi, numeri di telefono memorizzati come stringhe. Questo si chiama primitive obsession (ossessione per le primitive) — l'uso eccessivo di tipi primitivi per rappresentare concetti del dominio. È uno degli anti-pattern più comuni e più dannosi nello sviluppo PHP moderno.

I Value Objects sono la cura. Portano sicurezza dei tipi, validazione e chiarezza nel tuo codice — e una volta che inizi a usarli, ti chiederai come hai potuto vivere senza.

Cos'è un Value Object?

Un Value Object (VO, oggetto-valore) è un piccolo oggetto immutabile che rappresenta un concetto del tuo dominio. A differenza di un'Entity, un Value Object non ha identità — è definito interamente dai suoi valori. Due Value Objects con gli stessi valori sono considerati uguali.

Gli esempi classici includono:

  • Email — invece di una stringa grezza
  • Money — invece di un float più una stringa di valuta
  • UserId — invece di un intero
  • PhoneNumber — invece di una stringa
  • DateRange — invece di due oggetti DateTime
  • Address — invece di un array di stringhe
  • Password — invece di una semplice stringa

Il problema con le primitive

Considera questo codice tipico:

<?php function registerUser(string $email, float $balance): void { // $email è valido? Chissà. // $balance è in USD, EUR o BTC? Nessuna idea. // $balance può essere negativo? Forse. }

Ogni valore primitivo porta con sé assunzioni nascoste. Il sistema dei tipi non ti dice nulla su:

  • Se il valore è valido
  • In quale formato si trova
  • Quali operazioni sono consentite
  • Quali unità o valuta rappresenta
  • Se può essere modificato o riutilizzato in sicurezza

Il risultato? Logica di validazione sparsa ovunque, bug dovuti a formati non corrispondenti e codice difficile da ragionare.

Il tuo primo Value Object: Email

Sostituiamo una stringa primitiva con un Value Object appropriato:

<?php declare(strict_types=1); final class Email { private function __construct( private readonly string $value, ) { } public static function fromString(string $value): self { $normalized = mb_strtolower(trim($value)); if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) { throw new InvalidArgumentException( sprintf('"%s" is not a valid email address.', $value) ); } return new self($normalized); } public function toString(): string { return $this->value; } public function equals(self $other): bool { return $this->value === $other->value; } public function __toString(): string { return $this->value; } }

Ora il sistema dei tipi ti protegge. Se hai un oggetto Email, sai che è valido. Niente più controlli di validazione sparsi.

Usare il Value Object

<?php final class User { public function __construct( public readonly UserId $id, public readonly Email $email, public readonly string $name, ) { } } // Ora il costruttore impone la validità: $user = new User( id: UserId::fromInt(42), email: Email::fromString('John@Example.com'), name: 'John Doe', ); echo $user->email->toString(); // "john@example.com" (normalizzato)

Un esempio più complesso: Money

Il denaro è il caso classico in cui le primitive falliscono catastroficamente. I float perdono precisione, e mescolare valute è un bug silenzioso che aspetta solo di accadere.

<?php declare(strict_types=1); final class Money { private function __construct( private readonly int $amountInCents, private readonly string $currency, ) { if ($amountInCents < 0) { throw new InvalidArgumentException('Amount cannot be negative.'); } if (!preg_match('/^[A-Z]{3}$/', $currency)) { throw new InvalidArgumentException('Currency must be a 3-letter ISO code.'); } } public static function of(int $amountInCents, string $currency): self { return new self($amountInCents, $currency); } public static function fromFloat(float $amount, string $currency): self { return new self((int) round($amount * 100), $currency); } public function add(self $other): self { $this->assertSameCurrency($other); return new self( $this->amountInCents + $other->amountInCents, $this->currency, ); } public function subtract(self $other): self { $this->assertSameCurrency($other); return new self( $this->amountInCents - $other->amountInCents, $this->currency, ); } public function isGreaterThan(self $other): bool { $this->assertSameCurrency($other); return $this->amountInCents > $other->amountInCents; } public function amount(): int { return $this->amountInCents; } public function currency(): string { return $this->currency; } public function format(): string { return number_format($this->amountInCents / 100, 2) . ' ' . $this->currency; } public function equals(self $other): bool { return $this->amountInCents === $other->amountInCents && $this->currency === $other->currency; } private function assertSameCurrency(self $other): void { if ($this->currency !== $other->currency) { throw new InvalidArgumentException( sprintf('Cannot operate on %s and %s.', $this->currency, $other->currency) ); } } }

Ora guarda quanto diventa pulito il codice chiamante:

<?php $price = Money::fromFloat(19.99, 'USD'); $tax = Money::fromFloat(1.60, 'USD'); $total = $price->add($tax); echo $total->format(); // "21.59 USD" // Questo lancia un'eccezione invece di corrompere silenziosamente i dati: $price->add(Money::of(500, 'EUR'));

Caratteristiche chiave di un buon Value Object

  • Immutabile — una volta creato, non cambia mai. Le operazioni restituiscono nuove istanze.
  • Auto-validante — un'istanza non può esistere in uno stato non valido.
  • Senza identità — l'uguaglianza si basa sui valori, non su un ID del database.
  • Senza effetti collaterali — nessuna chiamata al database, nessun logging, nessuno stato globale.
  • Piccolo — rappresenta un concetto, non un intero aggregato.
  • Sostituibile — l'intero oggetto può essere scambiato, non mutato campo per campo.

Value Object vs. DTO vs. Entity

È importante distinguere questi tre pattern:

  • Value Object — immutabile, definito dai valori, contiene comportamento relativo a quei valori (come add() su Money).
  • DTO — immutabile o mutabile, trasporta dati tra i livelli, non contiene logica di business.
  • Entity — ha un'identità unica che persiste nel tempo, anche quando i suoi attributi cambiano.

Dove brillano i Value Objects

  • Modelli di dominio — rappresentazione di email, denaro, ID, date, indirizzi.
  • Validazione dell'input — creare un VO dall'input dell'utente garantisce la validità ovunque a valle.
  • Regole di business — incapsulamento di logica come la corrispondenza delle valute o i controlli sugli intervalli di date.
  • Testing — test più semplici, veloci e mirati senza dipendenze dal database.
  • Refactoring — quando i requisiti cambiano, modifichi il VO in un unico posto.

Insidie comuni da evitare

  • Aggiungere setter — questo rompe l'immutabilità. Se hai bisogno di un valore diverso, crea una nuova istanza.
  • Renderli troppo grandi — un VO dovrebbe rappresentare un concetto, non un'intera radice di aggregato.
  • Aggiungere logica di persistenza — mantieni le preoccupazioni del database nei repository, non nei VO.
  • Ignorare l'uguaglianza — implementa sempre un metodo equals().
  • Lanciare eccezioni generiche — definisci eccezioni specifiche del dominio per una gestione degli errori più chiara.

Un percorso di refactoring pratico

Non devi riscrivere l'intera applicazione dall'oggi al domani. Inizia da qui:

  1. Identifica un campo primitivo che causa bug o confusione (email, denaro, ID).
  2. Crea un Value Object per esso con validazione e metodi utili.
  3. Sostituisci la primitiva in una classe o un modulo.
  4. Lascia che il compilatore e i tuoi test guidino il resto.
  5. Ripeti con il prossimo candidato ovvio.

Conclusione

I Value Objects sono uno dei refactoring a più alto impatto che puoi fare in una codebase PHP. Eliminano l'ossessione per le primitive, centralizzano la validazione, rendono impossibile rappresentare stati non validi e trasformano il tuo codice in un'espressione auto-documentante del tuo dominio. Il sistema dei tipi diventa un alleato invece che una formalità.

Inizia con un singolo Value Object — magari Email o Money. Una volta che vedrai quanta chiarezza porta al tuo codice, non tornerai mai più a passare stringhe grezze in giro.

Smetti di scrivere codice ossessionato dalle primitive. Lascia che i tuoi tipi parlino per il tuo dominio.

Tecnologie e argomenti

Tag dell'articolo

Nessun articolo corrisponde a questi filtri.

Hai un progetto o un'idea da discutere?

Parliamone ↗