PHP OOP: progettare oggetti immutabili con i Value Object

by theArchitect
SHARE
PHP OOP: progettare oggetti immutabili con i Value Object
© Guida-HTML5.it

Introduzione

Un argomento molto utile nella programmazione orientata agli oggetti è la progettazione di Value Object, cioè oggetti che rappresentano valori del dominio applicativo, come un prezzo, un indirizzo email, una data o un codice fiscale.

A differenza di un’entità, un Value Object non viene identificato da un ID univoco. Due oggetti che contengono lo stesso valore rappresentano, dal punto di vista dell’applicazione, la stessa informazione. Per esempio, due oggetti Email con il valore [email protected] sono equivalenti.

Un buon Value Object dovrebbe essere normalmente immutabile: dopo la sua creazione, il suo stato non cambia. Se serve un nuovo valore, si crea un nuovo oggetto. Questa tecnica riduce gli effetti collaterali e rende il codice più semplice da testare e mantenere.

Nel seguente esempio costruiremo un piccolo modello OOP per gestire prodotti e prezzi. Il prezzo non sarà rappresentato da un semplice numero decimale, ma da una classe Money che conserva importo e valuta, esegue la validazione e impedisce modifiche accidentali.

Codice completo

<?php

declare(strict_types=1);

final class Money
{
    private int $amountInCents;
    private string $currency;

    public function __construct(int $amountInCents, string $currency)
    {
        if ($amountInCents < 0) {
            throw new InvalidArgumentException(
                ´L’importo non può essere negativo.´
            );
        }

        $currency = strtoupper(trim($currency));

        if (!preg_match(´/^[A-Z]{3}$/´, $currency)) {
            throw new InvalidArgumentException(
                ´La valuta deve essere un codice ISO di tre lettere.´
            );
        }

        $this->amountInCents = $amountInCents;
        $this->currency = $currency;
    }

    public function amountInCents(): int
    {
        return $this->amountInCents;
    }

    public function currency(): string
    {
        return $this->currency;
    }

    public function add(Money $other): Money
    {
        if ($this->currency !== $other->currency()) {
            throw new InvalidArgumentException(
                ´Non è possibile sommare valute differenti.´
            );
        }

        return new Money(
            $this->amountInCents + $other->amountInCents(),
            $this->currency
        );
    }

    public function multiply(int $quantity): Money
    {
        if ($quantity < 0) {
            throw new InvalidArgumentException(
                ´La quantità non può essere negativa.´
            );
        }

        return new Money(
            $this->amountInCents * $quantity,
            $this->currency
        );
    }

    public function format(): string
    {
        return number_format(
            $this->amountInCents / 100,
            2,
            ´,´,
            ´.´
        ) . ´ ´ . $this->currency;
    }
}

final class Product
{
    public function __construct(
        private readonly string $name,
        private readonly Money $price
    ) {
        if (trim($name) === ´´) {
            throw new InvalidArgumentException(
                ´Il nome del prodotto è obbligatorio.´
            );
        }
    }

    public function name(): string
    {
        return $this->name;
    }

    public function price(): Money
    {
        return $this->price;
    }
}

final class ShoppingCart
{
    /** @var Product[] */
    private array $products = [];

    public function add(Product $product, int $quantity = 1): void
    {
        if ($quantity < 1) {
            throw new InvalidArgumentException(
                ´La quantità deve essere almeno uno.´
            );
        }

        for ($i = 0; $i < $quantity; $i++) {
            $this->products[] = $product;
        }
    }

    public function total(): Money
    {
        if ($this->products === []) {
            return new Money(0, ´EUR´);
        }

        $total = new Money(0, $this->products[0]->price()->currency());

        foreach ($this->products as $product) {
            $total = $total->add($product->price());
        }

        return $total;
    }
}

$product = new Product(
    ´Tastiera meccanica´,
    new Money(8999, ´EUR´)
);

$cart = new ShoppingCart();
$cart->add($product, 2);

echo $cart->total()->format();
// Output: 179,98 EUR

Spiegazione

Rappresentare il denaro correttamente

La classe Money memorizza l’importo in centesimi tramite un intero. Questa scelta è preferibile all’uso diretto di float, perché i numeri a virgola mobile possono produrre imprecisioni durante i calcoli.

Per esempio, operazioni apparentemente semplici come 0.1 + 0.2 non sempre restituiscono esattamente 0.3 a causa della rappresentazione binaria dei numeri decimali. Memorizzando 8999 centesimi, l’importo rimane preciso.

Validazione nel costruttore

Il costruttore verifica che l’importo sia valido e che la valuta abbia il formato previsto. In questo modo un oggetto Money non può esistere in uno stato incoerente.

Questa è una caratteristica importante dell’incapsulamento: il codice esterno non può assegnare direttamente valori arbitrari alle proprietà private. Tutte le regole passano dal costruttore o dai metodi pubblici.

Immutabilità e metodi che restituiscono nuovi oggetti

Il metodo add() non modifica l’oggetto corrente. Restituisce invece una nuova istanza di Money. Lo stesso accade con multiply().

$price = new Money(1000, ´EUR´);
$newPrice = $price->multiply(3);

echo $price->format();
// 10,00 EUR

echo $newPrice->format();
// 30,00 EUR

Il valore originale resta invariato. Questo comportamento rende più prevedibile il flusso del programma, soprattutto quando lo stesso oggetto viene utilizzato in più punti.

Composizione tra oggetti

Product non eredita da Money e non contiene semplicemente un numero per il prezzo: contiene un oggetto Money. Questa tecnica si chiama composizione.

La composizione permette di assegnare a ogni classe una responsabilità precisa. Money gestisce regole e operazioni monetarie, mentre Product rappresenta il prodotto. Il carrello, infine, si occupa della raccolta dei prodotti e del calcolo del totale.

Uso di readonly

Le proprietà di Product sono dichiarate con readonly. Dopo l’inizializzazione non possono essere riassegnate. La classe Money non usa direttamente readonly sulle proprietà per mantenere compatibilità con versioni PHP precedenti, ma non espone metodi che possano modificarle.

Best practice

  • Usa Value Object per i valori importanti: email, denaro, coordinate, intervalli di date e identificativi possono avere regole proprie.
  • Valida presto: se un valore è invalido, genera l’errore durante la creazione dell’oggetto invece di rimandare il problema.
  • Preferisci gli interi per il denaro: memorizza i centesimi o utilizza una libreria specializzata per calcoli finanziari complessi.
  • Mantieni le proprietà private: evita di rendere lo stato modificabile liberamente dall’esterno.
  • Restituisci nuovi oggetti: nei Value Object, operazioni come somma, sottrazione o trasformazione dovrebbero normalmente preservare l’istanza originale.
  • Definisci metodi espressivi: amountInCents() e format() comunicano meglio l’intenzione rispetto all’accesso diretto a una proprietà.
  • Scrivi test automatici: verifica validazioni, somme tra valute diverse, moltiplicazioni e comportamento immutabile.
  • Non creare classi inutilmente: un Value Object è utile quando incapsula regole o semantica significativa, non solo per aumentare il numero di file.

Riepilogo

I Value Object immutabili aiutano a modellare il dominio in modo più preciso rispetto a stringhe e numeri primitivi. Nell’esempio, Money protegge l’importo da valori invalidi, impedisce calcoli tra valute diverse e mantiene la precisione usando i centesimi.

La composizione separa le responsabilità: il prodotto usa un prezzo, mentre il carrello coordina più prodotti. Grazie all’immutabilità, ogni operazione produce un nuovo valore senza modificare gli oggetti già esistenti. Il risultato è codice più prevedibile, leggibile e facile da testare.

Approfondisci con risorse ufficiali

SHARE