Y dale con los Value Objects

Estamos cansados de oir que si Value Objects por aquí, Value Objects por allá, pero ¿cual es realmente el atractivo de usar Value Objects y en qué nos puede ayudar?

A continuación veremos brevemente qué son los Value Object, qué nos aportan y un caso práctico en el que nos resulten útiles y toda esta complicación extra tenga alguna razón de ser.

¿Qué son los Value Object esos que tanto nombras?

Los Value Object no son más que objetos que envuelven un valor y arrastran con la envoltura las validaciones y la responsabilidad de mantener los datos en estado correcto.

Además, los Value Object son objetos que se identifican por su valor (como su nombre indica), no por un identificador, de modo que dos Value Object con el mismo contenido se consideran iguales.

Por último, pero no menos importante, los Value Object son objetos inmutables, es decir, su valor no puede cambiar una vez instanciado, si necesitamos modificar su valor, tendremos que instanciar un nuevo objeto con el nuevo valor.

Todo esto hace que los Value Objects sean objetos que una vez instanciados, se consideran válidos, pues son ellos los que tienen la responsabilidad de asegurar la validez de sus datos y su inmutabilidad.

Muy bien, pero ¿para qué sirve todo esto?

Con los Value Object se pretende centralizar en un único punto la validación de los datos, evitando tener las mismas validaciones desperdigadas por el sistema y evitando que cuando cambien los requisitos haya partes del sistema con diferentes validaciones que otras.

Además, consigue una alta cohesión, pues el Value Object es responsable de validar sus datos y solo sus datos, manteniendo la lógica de validación cerca de las propiedades y bien organizada.

También podemos facilitar tareas como formateos incluyéndolos en el propio Value Object, delegando así en quien tiene el conocimiento de sus propiedades el formateo adecuado del mismo y unificando este formateo en todas las partes del sistema, incluso permitiendo devolver el mismo formato aunque internamente sus propiedades cambien.

Ok, muy bonito, pero ponme un ejemplo que lo entienda bien.

Bien, hemos vuelto tras las vacaciones al trabajo, y ahí está esperando nuestra gente de producto que necesitan una nueva feature, pues unos administrativos necesitan introducir las direcciones a las que se deben enviar las notificaciones de los usuarios, y nosotros deberemos guardarlas.

Así que nos ponemos manos a la obra y hacemos un script para que introduzcan por consola, como nos han pedido, los datos de notificaciones de los usuarios.

Haremos que pongan primero el id de usuario, luego el e-mail, y después la dirección, compuesta por calle, número, código postal y provincia.

Instanciaremos un objeto NotificationPreferences y lo guardaremos en su repositorio.

Sencillo y para toda la familia, en 10 minutos nos ventilamos esta feature.

<?php

$repository = new NotificationPreferencesRepository();

$notificationPreferences = new NotificationPreferences(
  $argv[1],
  $argv[2],
  $argv[3],
  $argv[4],
  $argv[5],
  $argv[6],
);

$repository->save($notificationPreferences);

Ha sido fácil, y ya estamos entrando a ver ese curso que tenemos a medias cuando, de repente, los responsables del producto nos informan que están teniendo muchos errores de introducción del e-mail, que la gente se equivoca mucho y que necesitamos validarlo.

Así que nos ponemos a ello, total es añadir una comprobación nada más, en dos minutos está listo:

<?php
$repository = new NotificationPreferencesRepository();

if (!filter_var($argv[2], FILTER_VALIDATE_EMAIL)) {
  echo "Invalid email: " . $argv[2] . "\n";
  return;
}

$notificationPreferences = new NotificationPreferences(
  $argv[1],
  $argv[2],
  $argv[3],
  $argv[4],
  $argv[5],
  $argv[6],
);

$repository->save($notificationPreferences);

Listo, ya tenemos validado el segundo dato, que era el e-mail, si no es válido, daremos un mensaje indicándolo y detenemos la ejecución.

Y es entonces cuando suena el teléfono y nos indican que les ha gustado mucho la validación del e-mail, que hay que validar también los datos de la dirección postal. Tendremos que validar las longitudes de los elementos de la dirección según unos crierios que nos pasan.

Bueno, vale, está bien, vamos a añadir esas validaciones:

<?php
$repository = new NotificationPreferencesRepository();

if (!filter_var($argv[2], FILTER_VALIDATE_EMAIL)) {
  echo "Invalid email: " . $argv[2] . "\n";
  return;
}

if (strlen($argv[3]) > 250) {
  echo "Street too long: " . $argv[3] . "\n";
  return;
}

if (strlen($argv[3]) < 3) {
  echo "Street too short: " . $argv[3] . "\n";
  return;
}

if (strlen($argv[4]) > 10) {
  echo "Street number too long: " . $argv[4] . "\n";
  return;
}

if ($argv[4] === '') {
  echo "Street  number is required\n";
  return;
}

if (strlen($argv[5]) !== 5) {
  echo "Zip Code invalid: " . $argv[5] . "\n";
  return;
}

if (strlen($argv[6]) > 250) {
  echo "Province too long: " . $argv[3] . "\n";
  return;
}

if (strlen($argv[6]) < 3) {
  echo "Province too short: " . $argv[3] . "\n";
  return;
}


$notificationPreferences = new NotificationPreferences(
  $argv[1],
  $argv[2],
  $argv[3],
  $argv[4],
  $argv[5],
  $argv[6],
);

$repository->save($notificationPreferences);

Bien, esto está empezando a ser un script muy chungo, pero total, es un script para un uso, mañana lo tiramos a la basura, no merece la pena buscar otras opciones…

Pero vaya, ahora nos dicen que van a permitir que el propio usuario introduzca sus datos a través de la web y además que los administrativos importen excels para hacer este trabajo en bloques.

Nuestro script, que ya hacía aguas por todas partes, de repente se hunde rápidamente. Ahora tenemos dos opciones, copiamos estas validaciones en la web y en el script que importa los excel, o aprovechamos y probamos eso de lo que todo el mundo habla.

Aplicando Value Objects.

Como vimos, los Value Object además de envolver un valor, arrastran la responsabilidad y lógica encargada de la validación, así que parece exactamente lo que buscábamos, veamos cómo podemos hacer un Value Object para el primer elemento validado, la dirección de e-mail.

<?php

declare(strict_types=1);

namespace App\MailAddress;

class MailAddress
{
    private string $value;

    /**
     * @throws InvalidMailAddress
     */
    public function __construct(string $value)
    {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidMailAddress('Invalid e-mail: ' . $value);
        }

        $this->value = $value;
    }

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

}

Como vemos, tenemos un objeto que se ha llevado la validación del mismo, de modo que cuando se instancia, si no da error, podemos asumir que es correcto.

Ahora vamos a hacer lo propio con la dirección postal.

<?php

declare(strict_types=1);

namespace App\PostalAddress;

class PostalAddress
{
    private string $street;
    private string $number;
    private string $zipCode;
    private string $province;

    /**
     * @throws InvalidStreet
     * @throws InvalidStreetNumber
     * @throws InvalidZipCode
     * @throws InvalidProvince
     */
    public function __construct(string $street, string $number, string $zipCode, string $province)
    {
        $this->setStreet($street);
        $this->setNumber($number);
        $this->setZipCode($zipCode);
        $this->setProvince($province);
    }

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

    /**
     * @throws InvalidStreet
     */
    private function setStreet(string $street): void
    {
        if (strlen($street) > 250) {
            throw new InvalidStreet('Street is too long: ' . $street);
        }
        if (strlen($street) < 3) {
            throw new InvalidStreet('Street is too short: ' . $street);
        }
        $this->street = $street;
    }

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

    /**
     * @throws InvalidStreetNumber
     */
    private function setNumber(string $number): void
    {
        if (strlen($number) > 10) {
            throw new InvalidStreetNumber('Street number is too long: ' . $number);
        }
        if ($number === '') {
            throw new InvalidStreetNumber('Street number is required');
        }
        $this->number = $number;
    }

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

    /**
     * @throws InvalidZipCode
     */
    private function setZipCode(string $zipCode): void
    {
        if (strlen($zipCode) !== 5) {
            throw new InvalidZipCode('Invalid zip code: ' . $zipCode);
        }
        $this->zipCode = $zipCode;
    }

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

    /**
     * @throws InvalidProvince
     */
    private function setProvince(string $province): void
    {
        if (strlen($province) > 250) {
            throw new InvalidProvince('Province is too long: ' . $province);
        }
        if (strlen($province) < 3) {
            throw new InvalidProvince('Province is too short: ' . $province);
        }
        $this->province = $province;
    }
}

Ya tenemos modelados los Value Object y sus validaciones, pero ahora vamos a adaptar nuestro código para usarlos.

Fijaos que hemos creado unos setters para encapsular la validación de cada propiedad, pero que son privados, solo podemos usarlo dentro del objeto, consiguiendo un código más legible en el constructor, pero manteniendo la inmutabilidad del objeto.

Bien, ahora utilicemos estos Value Objects en nuestro script.

try {
  $repository = new NotificationPreferencesRepository();

  $notificationPreferences = new NotificationPreferences(
    $argv[1],
    new EmailAddress($argv[2]),
    new PostalAddress($argv[3], $argv[4], $argv[5], $argv[6]),
  );

  $repository->save($notificationPreferences);
} catch (Throwable $e) {
  echo $e->getMessage() . "\n";
}

Podemos eliminar todas las validaciones y pasarle al objeto NotificationPreferences el id del usuario, un objeto EmailAddress, y un objeto PostalAddress. Al instanciarse estos objetos ya se encargarán de lanzar una excepción si tienen algún error, de lo contrario, se instanciarán con un estado válido siempre.

Además, podemos emplear estos mismos objetos en el proceso que permite a los usuarios poner sus preferencias de notificaciones y la importación por bloques, por no hablar de la facilidad que tenemos ahora para modificar los requisitos, pues por ejemplo ahora podemos cambiar la validación del código postal por una más completa y específica, y se aplicará en todos los sitios.

Utilizando los Value Object para obtener datos formateados.

Ya hemos visto que con los Value Object podemos unificar su validación y asegurarnos de que una vez instanciados, tendrán datos válidos. Pero, ¿qué tal si además le añadimos algo más de comportamiento específico?

Por ejemplo, en el caso de la dirección postal, estamos empezando a generar notificaciones que se enviarán en papel, por lo que debemos generar una carta con los datos de envío, y también tenemos que generar un listado donde indican dónde se han enviado dichas cartas.

En ambos casos tenemos formatos de dirección diferentes, en las cartas físicas tenemos los datos en dos líneas, con la calle y el número en una, y el código postal y provincia en otra. En el listado lo tenemos en una sola línea.

El problema es que tenemos muchos tipos de cartas, y en cada una de ellas tenemos que formatear la dirección del mismo modo, lo mismo con los listados, empieza a haber muchos y tenemos que modificar el formato en el que pintamos la dirección.

Vamos a llevarnos ese formateo al Value Object PostalAddress, añadiendo dos métodos para sacar la dirección en cada uno de los formatos.

<?php

declare(strict_types=1);

namespace App\PostalAddress;

class PostalAddress
{
    private string $street;
    private string $number;
    private string $zipCode;
    private string $province;

    /**
     * @throws InvalidStreet
     * @throws InvalidStreetNumber
     * @throws InvalidZipCode
     * @throws InvalidProvince
     */
    public function __construct(string $street, string $number, string $zipCode, string $province)
    {
        $this->setStreet($street);
        $this->setNumber($number);
        $this->setZipCode($zipCode);
        $this->setProvince($province);
    }

    public function oneLine(): string
    {
        return $this->street . ', ' . $this->number . ' - ' . $this->zipCode . ' ' . $this->province;
    }

    public function forLetter(): string
    {
        return $this->street . ", " . $this->number . "\n" . $this->zipCode . " " . $this->province;
    }

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

    /**
     * @throws InvalidStreet
     */
    private function setStreet(string $street): void
    {
        if (strlen($street) > 250) {
            throw new InvalidStreet('Street is too long: ' . $street);
        }
        if (strlen($street) < 3) {
            throw new InvalidStreet('Street is too short: ' . $street);
        }
        $this->street = $street;
    }

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

    /**
     * @throws InvalidStreetNumber
     */
    private function setNumber(string $number): void
    {
        if (strlen($number) > 10) {
            throw new InvalidStreetNumber('Street number is too long: ' . $number);
        }
        if ($number === '') {
            throw new InvalidStreetNumber('Street number is required');
        }
        $this->number = $number;
    }

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

    /**
     * @throws InvalidZipCode
     */
    private function setZipCode(string $zipCode): void
    {
        if (strlen($zipCode) !== 5) {
            throw new InvalidZipCode('Invalid zip code: ' . $zipCode);
        }
        $this->zipCode = $zipCode;
    }

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

    /**
     * @throws InvalidProvince
     */
    private function setProvince(string $province): void
    {
        if (strlen($province) > 250) {
            throw new InvalidProvince('Province is too long: ' . $province);
        }
        if (strlen($province) < 3) {
            throw new InvalidProvince('Province is too short: ' . $province);
        }
        $this->province = $province;
    }
}

Ahora, siempre que necesitemos formatear una dirección para una carta, invocaremos al método forLetter y cuando vayamos a formatear para listados, invocaremos al método oneLine, y si mañana debemos cambiar este formato, lo tendremos en un sitio lógico y unificado.

Algunos usos más

Además de lo comentado hasta ahora, los Value Object también aportan semántica al código, agrupando o envolviendo escalares en objetos propios del dominio, lo que facilita la comprensión y legibilidad del código.

Por último, a veces tenemos Value Objects que pueden tener valores muy concretos, como por ejemplo el perfil de un usuario y podemos aprovechar que estan encapsulados para crear constructores semánticos que nos simplifiquen la instanciación sin conocer los valores internos y métodos semánticos que comprueben, por ejemplo, si un perfil es de administrador.

<?php

declare(strict_types=1);

namespace App;

class UserProfile
{
    private const SUPERADMIN = 'super admin';
    private const ADMIN = 'admin';
    private const NO_ADMIN = 'plain user';

    private string $value;

    public function __construct(string $value)
    {
        $this->value = $value;
    }

    public static function superAdmin(): self
    {
        return new self(self::SUPERADMIN);
    }

    public static function admin(): self
    {
        return new self(self::ADMIN);
    }

    public static function noAdmin(): self
    {
        return new self(self::NO_ADMIN);
    }

    public function isAdmin(): bool
    {
        return $this->value !== self::NO_ADMIN;
    }

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

En resumen...

Espero que os sirva de ayuda para entender el valor que aportan estos extra de trabajo y cuanto nos facilitan la vida en proyectos a largo plazo y con cambios de requisitos y crecimiento constante.

  • Ganamos en mantenibilidad al unificar en un único punto la lógica.
  • Ganamos en legibilidad, eliminando el ruido de las validaciones del código del script.
  • Ganamos en seguridad de los datos, pues si un objeto se instancia, su valor es válido e inmutable.