Deja atrás lo primitivo
En el que se habla de que si estamos siguiendo un paradigma orientado a objetos, todo debería ser un objeto. Sí, todo.
Notas de la segunda edición
Este capítulo necesitaba una fuerte reescritura, dado que estaba muy orientado a Value Objects, que es un concepto muy ligado a una metodología concreta de desarrollo. En general, encapsular comportamiento y datos en objetos es una buena idea, siempre y cuando representen algún tipo de concepto significativo.
Otro cambio destacable es que hemos incluído aquí el contenido correspondiente a los tipos Enumerables. Al revisar ese capítulo nos hemos dado cuenta de que en realidad el capítulo de Enumerables estaba hablando del patrón State, que hemos movido a un capítulo dedicado.
Está lleno de objetos
Todos los lenguajes de programación vienen de serie con un conjunto de tipos de datos básicos que denominamos primitivos. En algún caso también se les llama escalares, cuando el lenguaje no los implementa nativamente como objetos: boolean, integer, float o string, entre otros, que utilizamos para representar cosas y operar con ellas. La parte mala es que se trata de tipos de datos muy genéricos y, a veces, necesitaríamos algo que aporte más significado y también más restricciones.
Los lenguajes orientados a objetos, en particular, ofrecen una estructura nativa de datos que permite encapsular primitivos y comportamiento en objetos. De este modo, podemos representar conceptos significativos en distintos dominios y niveles de abstracción. En algunos lenguajes también existe el tipo Struct, con el que podemos representar estructuras de datos simples, pero que no tienen comportamiento. Me voy a permitir hacer una clasificación de diversos tipos de objetos que podríamos tener en una aplicación:
- Data Transfer Object (DTO): son objetos que se utilizan para transferir datos entre subsistemas de una aplicación. No tienen comportamiento y suelen ser muy simples, con propiedades públicas y sin métodos. Sus propiedades son tipos primitivos. Su ventaja es que son fáciles de serializar y deserializar. Por otro lado, podríamos incluir en esta categoría cualquier tipo de objeto que definimos con la única función de agrupar datos de forma arbitraria para mover información de un sitio a otro.
-
Tipos: son objetos tienen la función de suplementar el sistema de tipos del lenguaje para añadir algunas restricciones y aportar significado a los datos. Imagina, por ejemplo, que quieres tener cosas como
NotEmptyString,PositiveNumbery similares. Estos tipos no representan conceptos de un dominio específico, sino que son simplemente tipos de datos que añaden restricciones a los primitivos. Estos objetos tendrían un comportamiento fundamentalmente técnico y genérico. -
Objetos: en general, cualquier objeto que encapsula primitivos y comportamiento para representar algún concepto significativo del contexto en que se usa. La palabra dominio sería más correcta, pero está muy contaminada por el uso que se le da en DDD, por lo que prefiero evitarla. Por poner un ejemplo, la configuración de una base de datos podría representarse mediante un objeto
DatabaseConfiguration, o un objetoFilepara representar un archivo en un sistema de almacenamiento, y un largo etcétera de casos. Pero, por supuesto, usaríamos objetos para representar conceptos de negocio, comoCustomer,Product,Order, etc.
En Domain Driven Design se habla de Value Objects y Entities. Ambos representan conceptos importantes del Dominio de Negocio de una aplicación. La principal diferencia es que los Value Objects nos interesan por su valor y son inmutables, mientras que las Entities nos interesan por su identidad y mutan a lo largo de su ciclo de vida.
El problema de los tipos primitivos
Validación. Cuando queremos representar algún concepto en código inicialmente recurrimos a los tipos disponibles. Pensemos, por ejemplo, en un email, el cual usamos frecuentemente como nombre de usuario porque es único y fácil de recordar. Puesto que lo podemos representar con un tipo string, es habitual encontrarnos con código como este:
1 $username = "user@example.com";
El problema es que un email no es un string cualquiera. Tiene una serie de restricciones que no se cumplen en un simple string. Por ejemplo, tiene que tener un formato concreto, con una longitud máxima, incluir el símbolo @, al menos un nombre de dominio, etc. Si queremos validar un email, tendremos que hacerlo manualmente. Esta sería una estrategia típica en PHP:
1 function isValidEmail(string $email): bool
2 {
3 return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
4 }
En cualquier caso, como $username es una variable, sería fácil que en cualquier momento cambie. En consecuencia nunca vamos a poder tener la seguridad de que $username es un email válido fuera del scope en que se haya validado, por lo que tenemos que repetir esa validación siempre.
Conceptos compuestos. Es muy frecuente que un concepto único se tenga que modelar con varios elementos. Así, por ejemplo, el nombre de una persona suele constar de nombre de pila y apellidos, para lo que podrían usarse dos strings:
1 $firstName = "Pepa";
2 $lastName = "Pérez García";
El problema obvio es que tenemos que tener esto en cuenta constantemente y mover estas variables juntas a todas partes:
1 function createFullName(string $firstName, string $lastName): string
2 {
3 return $firstName . ' ' . $lastName;
4 }
Lo mismo ocurre aquí, que siempre tendremos que pasar ambos datos:
1 class Customer
2 {
3 private string $id;
4 private string $name;
5 private string $lastName;
6
7 public function __construct(string $id, string $name, string $lastN\
8 ame) {
9 $this->id = $id;
10 $this->name = $name;
11 $this->lastName = $lastName;
12 }
13 }
La necesidad de mantener juntos un conjunto de datos da lugar a un code smell llamado Data Clump. Este tipo de diseños son costosos de mantener y cambiar, ya que es fácil olvidar todos los elementos que tienen que mantenerse juntos y tenemos que hacer seguimiento de ellos en todos los rincones del código.
Primitive Obsession
En último término, usar tipos primitivos para representar cualquier tipo de concepto provoca el code smell llamado Primitive Obsession. Aunque en muchos contextos es perfectamente válido usar tipos primitivos, cuando estamos modelando un dominio complejo es preferible usar objetos. Los objetos nos permitirán resolver los problemas anteriores de una forma segura y elegante.
La resolución de Primitive Obsession es, frecuentemente, Replace Data with Object, que es un refactor en el que encapsulamos el dato, o conjunto de datos, primitivo en un objeto simple.
1 class Email
2 {
3 private string $email;
4
5 public function __construct(string $email)
6 {
7 $this->email = $email;
8 }
9
10 public function __toString(): string
11 {
12 return $this->email;
13 }
14 }
Al principio vamos a tener muchas situaciones en las que querremos tener el equivalente primitivo del objeto, por lo que necesitaremos algún tipo de getter. Sin embargo, muchos lenguajes ofrecen la posibilidad de hacer un type casting de un objeto a un primitivo, lo que nos permitirá usar el objeto en cualquier lugar donde se necesite el primitivo. Así, por ejemplo, en PHP se puede contar con el método mágico __toString() el cual es invocado automáticamente cuando hacemos el casting o bien en contextos en el que el dato esperado es un string.
1 $email = new Email("fran.iglesias@example.com");
2
3 echo (string)$email;
En cualquier caso, una buena recomendación que podemos seguir es asegurar que estos métodos proporcionen una representación primitiva fácilmente parseable. Es decir, que teniendo el string que devuelve __toString() podamos reconstruir el objeto original.
Ya tenemos el objeto, ¿qué ventajas obtenemos?
Así, por ejemplo, podemos hacer que un objeto se cree siempre con valores adecuados, impidiendo que se pueda instanciar si no se cumplen las condiciones requeridas. Veamos aquí el ejemplo con el email:
1 class Email
2 {
3 private string $email;
4
5 public function __construct(string $email)
6 {
7 if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
8 throw new InvalidArgumentException('Invalid email address');
9 }
10 $this->email = $email;
11 }
12
13 public function __toString(): string
14 {
15 return $this->email;
16 }
17 }
En consecuencia, si tenemos un objeto de tipo Email siempre tendremos la seguridad de que es válido, puesto que no se puede instanciar con valores que no pasen la validación. Además, si necesitamos el email como string, podemos hacer type casting y obtenerlo. Entre otras ventajas, conseguimos reducir la cantidad de código repetitivo que necesitamos para validar los emails. Y en caso de que la validación cambie, solo tendremos que aplicar el cambio en un único lugar, garantizando la coherencia entre partes distintas de la aplicación.
Algunos autores, como Yegor Bugayenko, sostienen que este tipo de constructores con validación no son correctos. La razón es que el constructor debería ser lo más simple posible, y la validación debería hacerse en un método aparte. Entre otras razones, porque hay situaciones en las que podemos confiar en la validez de los parámetros de entrada. Su propuesta es utilizar constructores secundarios que, en el caso de PHP y otros lenguajes, se implementan como métodos de clase estáticos.
1 class Email
2 {
3 private string $email;
4
5 private function __construct(string $email)
6 {
7 $this->email = $email;
8 }
9
10 public static function fromString(string $email): self
11 {
12 return new self($email);
13 }
14
15 public static function valid(string $email): self
16 {
17 if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
18 throw new InvalidArgumentException('Invalid email address');
19 }
20 return new self($email);
21 }
22
23 public function __toString(): string
24 {
25 return $this->email;
26 }
27 }
Resolviendo Data Clump
La solución a Data Clump, como hemos señalado arriba, es Replace Data with Object, encapsulando los datos en un objeto que represente el concepto que estamos modelando. Hay algunos contextos específicos en el que este smell puede resolverse con Introduce Parameter Object, que es un refactor que consiste en agrupar los parámetros que pasamos a una función en objetos ad hoc. Vamos a aplicarlo al problema del nombre de una persona.
1 $firstName = "Pepa";
2 $lastName = "Pérez García";
Podemos crear un objeto SimpleName que encapsule el nombre y el apellido:
1 class SimpleName
2 {
3 private string $name;
4 private string $lastName;
5
6 public function __construct(string $name, string $lastName)
7 {
8 $this->name = $name;
9 $this->lastName = $lastName;
10 }
11
12 public function __toString(): string
13 {
14 return $this->name . ' ' . $this->lastName;
15 }
16 }
Ahora podemos modificar la clase Customer para que use este objeto. Este es el objeto Customer que presentamos antes:
1 class Customer
2 {
3 private string $id;
4 private string $name;
5 private string $lastName;
6
7 public function __construct(string $id, string $name, string $lastN\
8 ame) {
9 $this->id = $id;
10 $this->name = $name;
11 $this->lastName = $lastName;
12 }
13 }
Y este es el objeto Customer que usa SimpleName:
1 class Customer
2 {
3 private string $id;
4 private SimpleName $personName;
5
6 public function __construct(string $id, SimpleName $personName) {
7 $this->id = $id;
8 $this->personName = $personName;
9 }
10 }
Ahora bien, hay muchos contextos en los que probablemente la información necesaria para inicializar Customer venga en forma de datos primitivos. ¿Deberíamos usar estos directamente o instanciar un SimpleName primero? Por supuesto, la respuesta es que depende. Una solución es introducir varios Factory Method que permitan la creación de un objeto Customer de distintas formas, adecuadas para diferentes situaciones. En este ejemplo, exponemos dos: uno que recibe un nombre y un apellido y otro que recibe un objeto SimpleName.
1 class Customer
2 {
3 private string $id;
4 private SimpleName $personName;
5
6 private function __construct(string $id, SimpleName $personName) {
7 $this->id = $id;
8 $this->personName = $personName;
9 }
10
11 public static function fromName(string $id, string $name, string $l\
12 astName): self
13 {
14 $customer = new self($id, new SimpleName($name, $lastName));
15
16 return $customer;
17 }
18
19 public static function fromSimpleName(string $id, SimpleName $perso\
20 nName): self
21 {
22 $customer = new self($id, '');
23 $customer->personName = $personName;
24
25 return $customer;
26 }
27 }
El constructor nativo, primario o canónico, instancia Customer con datos ya encapsulados, que sería la forma canónica, y lo ponemos privado para que no se pueda usar directamente. En su lugar, usamos los métodos de clase fromName y fromSimpleName para crear instancias de Customer, según sea nuestro contexto.
DTOs
Los DTO son objetos muy simples que no tienen comportamiento. Algunos lenguajes utilizan el tipo nativo Struct para representar este tipo de objetos. En otros, podemos usar clases con propiedades públicas, a ser posible de solo lectura, ya que deben ser inmutables. Estas propiedades serán de tipos primitivos del lenguaje, de este modo, la serialización y deserialización de los objetos será más sencilla.
1 class CustomerDTO
2 {
3 public readonly string $id;
4 public readonly string $name;
5 public readonly string $lastName;
6 public readonly string $email;
7 }
Los DTO se utilizan para transferir datos entre subsistemas de una aplicación. Ni siquiera tendrían que ser una representación exacta de un concepto del dominio, sino que simplemente agrupan datos para moverlos de un sitio a otro. Por ejemplo, un DTO que represente los datos de un formulario que se envía a un servidor, o bien un DTO que represente los datos que se devuelven en una respuesta de una API.
Podemos usar DTO para modelar Comandos, Queries y Eventos, ya que son mensajes que emite un subsistema o capa para que otro subsistema, o capa, los interprete y haga algo en respuesta. En general, los DTO son útiles para desacoplar subsistemas y capas, y permiten que los datos se muevan de un sitio a otro sin que los subsistemas tengan que conocerse entre sí.
1 class CreateCustomerCommand
2 {
3 public readonly string $id;
4 public readonly string $name;
5 public readonly string $lastName;
6 public readonly string $email;
7 }
Un uso de los DTO sería resolver el problema de los Data Clumps que mencionábamos antes. Si tenemos un conjunto de datos que se mueve siempre junto, podemos encapsularlos en un DTO y moverlos juntos. Por ejemplo, en el caso de una función que tenga muchos parámetros, podríamos reemplazarlos por un DTO.
1 function createCustomer(string $id, string $name, string $lastName, str\
2 ing $email): void
3 {
4 $customer = new Customer($id, $name, $lastName, $email);
5 // ...
6 }
Algo parecido a esto:
1 function createCustomer(CreateCustomerCommand $command): void
2 {
3 $customer = new Customer($command->id, $command->name, $command->la\
4 stName, $command->email);
5 // ...
6 }
Este refactor se llama Introduce Parameter Object, y es una forma de agrupar los parámetros que pasamos a una función en un objeto que agrupa los datos. Una de las ventajas es que cada dato está identificado por el nombre de la propiedad, lo que hace que sea más fácil de entender qué datos se están pasando a la función y no tener preocuparnos de hacerlo en el orden correcto.
1 $pepaCustomer = new CreateCustomerCommand();
2
3 $pepaCustomer->id = "123";
4 $pepaCustomer->name = "Pepa";
5 $pepaCustomer->lastName = "Pérez García";
6 $pepaCustomer->email = "pepa@example.com";
7
8 createCustomer($pepaCustomer);
Tipos
Cuando el sistema de tipos del lenguaje no nos proporciona suficientes garantías, o bien cuando consideramos ciertas restricciones que debemos aplicar frecuentemente, podemos introducir objetos que, sin llegar a representar conceptos de un dominio, nos permiten encapsular ciertas reglas genéricas.
Por ejemplo, imaginemos que queremos representar un número entero que no puede ser negativo. Podríamos hacer algo así:
1 class NonNegative
2 {
3 private int $value;
4
5 public function __construct(int $value)
6 {
7 if ($value < 0) {
8 throw new InvalidArgumentException('Value must be non-negat\
9 ive');
10 }
11 $this->value = $value;
12 }
13
14 public function __toString(): string
15 {
16 return (string) $this->value;
17 }
18 }
De este modo, si necesitamos un número no negativo, simplemente creamos un objeto NonNegative y ya tenemos la garantía de que el valor será correcto. Algo similar si lo que necesitamos es strings no vacíos, que sería un requisito habitual para muchos campos de texto:
1 class NonEmptyString
2 {
3 private string $value;
4
5 public function __construct(string $value)
6 {
7 if ('' === $value) {
8 throw new InvalidArgumentException('Value must be non-empty\
9 ');
10 }
11 $this->value = $value;
12 }
13
14 public function __toString(): string
15 {
16 return $this->value;
17 }
18 }
Ahora, podríamos definir un objeto de dominio que represente un Customer de la siguiente manera, de tal modo que garantizamos que los datos que contiene son válidos:
1 class Customer
2 {
3 private string $id;
4 private NonEmptyString $personName;
5 private NonNegative $age;
6
7 public function __construct(string $id, string $personName, int $ag\
8 e)
9 {
10 $this->id = $id;
11 $this->personName = new NonEmptyString($personName);
12 $this->age = new NonNegative($age);
13 }
14 }
Un efecto secundario beneficioso es que leyendo la definición de Customer podemos visualizar también las reglas de validación estructural que se le aplican.
Una observación muy importante es que estos objetos no deben usarse como clases base para derivar Value Objects. No tienen un significado en el dominio, sino que son simplemente objetos que encapsulan ciertas reglas. Los usaremos siempre por composición, como si fuesen tipos nativos.
Enumerables
Es bastante frecuente encontrarnos con ciertos conceptos que se pueden representar con un número finito, reducido y fijo, de valores posibles. Por ejemplo, el estado de un pedido, que puede ser PENDING, SHIPPED, DELIVERED, etc. O bien, el tipo de un producto, que puede ser PHYSICAL, DIGITAL, SERVICE, etc. Y también es el caso de las opciones de un menú, categorías de clasificación, y un largo etcétera.
Cuando no es previsible que estos valores cambien podemos representarlos con un tipo Enumerable. En pocas palabras, un tipo Enumerable es aquel que tiene un número finito y fijo de valores posibles. Por tanto, para instanciar un objeto pasamos el valor y lo validamos contra los valores posibles.
1 class OrderStatus
2 {
3 private const PENDING = 'PENDING';
4 private const SHIPPED = 'SHIPPED';
5 private const DELIVERED = 'DELIVERED';
6
7 private string $status;
8
9 public function __construct(string $status)
10 {
11 if (!in_array($status, [self::PENDING, self::SHIPPED, self::DEL\
12 IVERED])) {
13 throw new InvalidArgumentException('Invalid status');
14 }
15 $this->status = $status;
16 }
17
18 public function __toString(): string
19 {
20 return $this->status;
21 }
22 }
Esto nos permite tener la seguridad de que el estado de un pedido siempre será uno de los valores posibles. Además, si necesitamos añadir un nuevo estado, solo tendremos que modificar la clase OrderStatus.
Ahora bien, esto se refiere a la etiqueta o valor que representa el estado, sobre todo en lo que se refiere a contextos en los que vamos a serializar o deserializar estos valores.
En muchos casos, necesitaremos también un comportamiento asociado, como sería el caso de un OrderStatus que nos permita saber si un pedido está pendiente, enviado o entregado, para determinar si podemos hacer progresar ese pedido a una nueva fase. Lo habitual es consultar de qué estado se trata para decidir qué hacer a continuación. Entonces, lo que necesitamos es un objeto que encapsule el estado y el comportamiento asociado, lo que se conoce como patrón State, del cual hablaremos extensamente en el capítulo correspondiente.
La línea que separa el uso de un tipo Enumerable y un objeto State es bastante difusa, y en muchos casos se solapan. En general, si necesitamos un objeto que represente un estado y tenga comportamiento asociado, lo que necesitamos es un patrón State, representando los distintos estados no ya como valores, sino como objetos. Si solo necesitamos representar un valor que puede ser uno de varios posibles, usaremos un tipo Enumerable.
Por supuesto, en el contexto del patrón State, podemos representar esos posibles valores en forma de Enumerable, al menos de cara a serializar o deserializar, a fin de obtener valores consistentes o reconstruir el objeto original.
Objetos
Si bien los tipos no dejan de ser objetos, aquí pondremos el acento en aquellos que representan conceptos significativos en el dominio. Nos vienen a la cabeza, un Customer o un Product, que son habituales en muchos negocios, pero también podemos representar mediante objetos un File, una Configuration, un Mapper y, en general, cualquier concepto dentro de un programa que se pueda tratar como una unidad capaz de exponer comportamiento.
He aquí un ejemplo simple: un objeto File que abstrae la capacidad de escribir y leer el contenido de archivos en el sistema de archivos local. Este objeto encapsula la lógica de lectura y escritura, y nos permite trabajar con archivos de una forma más sencilla, segura y consistente.
1 class File
2 {
3 private string $filePath;
4
5 public function __construct(string $filePath)
6 {
7 $this->filePath = $filePath;
8 }
9
10 public function read(): string
11 {
12 if (!file_exists($this->filePath)) {
13 throw new \RuntimeException("File not found: {$this->filePa\
14 th}");
15 }
16
17 $contents = file_get_contents($this->filePath);
18 if ($contents === false) {
19 throw new \RuntimeException("Failed to read file: {$this->f\
20 ilePath}");
21 }
22
23 return $contents;
24 }
25
26 public function write(string $contents): void
27 {
28 $result = file_put_contents($this->filePath, $contents);
29 if ($result === false) {
30 throw new \RuntimeException("Failed to write to file: {$thi\
31 s->filePath}");
32 }
33 }
34 }
Value Objects
Los value objects son un tipo de objetos que representan algún concepto importante en el dominio de negocio de la aplicación. En resumen, los value objects:
- Representan conceptos importantes o interesantes del dominio, entendido como el dominio de conocimiento que toca el código que estamos implementando o estudiando.
- Siempre son creados consistentes, de modo que si obtienes una instancia puedes tener la seguridad de que es válida. De otro modo, no se crean y se lanza una excepción.
- Los objetos nos interesan por su valor, no por su identidad, por lo que tienen que tener alguna forma de chequear su igualdad.
- Son inmutables: su valor no puede cambiar durante su ciclo de vida. En caso de que tengan métodos mutators, estos devolverán una nueva instancia de la clase con el valor modificado.
- Encapsulan comportamientos. Los buenos value objects atraen y encapsulan comportamientos que pueden ser utilizados por el resto del código.
Los value objects pueden ser genéricos y reutilizables, como Money, o muy específicos de un dominio.
Una aclaración que me gustaría hacer es value object es uno de los bloques de construcción en Domain Driven Design, pero el patrón de encapsular valores primitivos en objetos lo podemos, y debemos, aplicar en cualquier tipo de diseño orientado a objetos.
Refactorizar a value objects
Refactorizar a value objects puede ser una tarea de bastante calado, ya que implica crear nuevas clases y utilizarlas en diversos puntos del código. Ahora bien, este proceso puede hacerse de forma bastante gradual. Ten en cuenta que:
- Los value objects no tienen dependencias, para crearlos solo necesitas primitivos o bien otros value objects.
- Los value objects se pueden instanciar allí donde los necesites, son newables.
- Normalmente, tendrás métodos para convertir los value objects a escalares, de modo que puedas utilizar sus valores con código que no puedes modificar.
Los value objects aportan varias ventajas:
- Al encapsular su validación tendrás objetos con valores adecuados que puedes usar libremente sin necesidad de validar constantemente.
- Aportarán significado a tu código, siempre sabrás cuando una variable es un precio, un email, una edad, lo que necesites.
- Te permiten abstraerte de cuestiones como formato, precisión, etc.
Un ejercicio para aprender a usar objetos
Veamos un objeto típico de cualquier negocio: Customer que da lugar a varios ejemplos clásicos de value object. Un cliente siempre suele tener un nombre, que acostumbra a ser una combinación de nombre de pila y uno o más apellidos. También tiene una dirección, que es una combinación de unos cuantos datos.
El siguiente ejercicio que vamos a hacer se inspira en una regla de Object Calisthenics, que nos pide que una clase no tenga más de dos propiedades. Hacer este ejercicio te ayudará a identificar conceptos compuestos en tus objetos. En este caso, vamos a ver cómo podemos aplicar esta regla a un objeto Customer que ahora mismo tiene muchas propiedades:
1 class Customer
2 {
3 private $id;
4 private $name;
5 private $firstSurname;
6 private $lastSurname;
7 private $street;
8 private $streetNumber;
9 private $floor;
10 private $postalCode;
11 private $city;
12 }
El constructor de nuestro Customer podría ser muy complicado, y eso que no hemos incluido todos los campos:
1 class Customer
2 {
3 private $id;
4 private $name;
5 private $firstSurname;
6 private $lastSurname;
7 private $street;
8 private $streetNumber;
9 private $floor;
10 private $postalCode;
11 private $city;
12
13 public function __construct(
14 string $id,
15 string $name,
16 string $firstSurname,
17 ?string $lastSurname,
18 string $street,
19 string $streetNumber,
20 string $floor,
21 string $postalCode,
22 string $city
23 )
24 {
25 $this->id = $id;
26 $this->name = $name;
27 $this->firstSurname = $firstSurname;
28 $this->lastSurname = $lastSurname;
29 $this->street = $street;
30 $this->streetNumber = $streetNumber;
31 $this->floor = $floor;
32 $this->postalCode = $postalCode;
33 $this->city = $city;
34 }
35
36 public function fullName(): string
37 {
38 $fullName = $this->name . ' ' . $this->firstSurname;
39
40 if ($this->lastSurname) {
41 $fullName .= ' ' . $this->lastSurname;
42 }
43
44 return $fullName;
45 }
46
47 public function address(): string
48 {
49 $address = $this->street . ', ' . $this->streetNumber;
50
51 if ($this->floor) {
52 $address .= ' '. $this->floor;
53 }
54
55 $address .= $this->postalCode. '-'.$this->city;
56
57 return $address;
58 }
59 }
Solemos decir que las cosas que cambian juntas deben ir juntas, pero eso también implica que las cosas que no cambian juntas deberían estar separadas. En el constructor van todos los detalles mezclados y se hace muy difícil de manejar. De hecho, como prácticamente todos los campos son del mismo tipo, es fácil confundirlos. Un error en el orden de los parámetros puede ser muy difícil de detectar.
Una forma de abordar esto es introducir un patrón Builder:
1 class CustomerBuilder
2 {
3 private $name;
4 private $firstSurname;
5 private $lastSurname;
6 private $street;
7 private $streetNumber;
8 private $floor;
9 private $postalCode;
10 private $city;
11
12 public function withName(string $name, string $firstSurname, ?strin\
13 g $lastSurname) : self
14 {
15 $this->name = $name;
16 $this->firstSurname = $firstSurname;
17 $this->lastSurname = $lastSurname;
18
19 return $this;
20 }
21
22 public function withAddress(string $street, string $streetNumber, s\
23 tring $floor, string $postalCode, string $city): self
24 {
25 $this->street = $street;
26 $this->streetNumber = $streetNumber;
27 $this->floor = $floor;
28 $this->postalCode = $postalCode;
29 $this->city = $city;
30
31 return $this;
32 }
33
34 public function build() : Customer
35 {
36 return new Customer(
37 $this->id,
38 $this->name,
39 $this->firstSurname,
40 $this->lastSurname,
41 $this->street,
42 $this->streetNumber,
43 $this->floor,
44 $this->postalCode,
45 $this->city
46 );
47 }
48 }
La ventaja del patrón Builder es que nos permite ocultar la complejidad del constructor canónico, introduciendo una interfaz de construcción más significativa. Observa el siguiente código. El resultado es el mismo, pero la forma de construir el objeto es mucho más clara, y eso que la dirección se las trae, con ni más ni menos que cinco campos:
1 $customerBuilder = new CustomerBuilder();
2
3 $customer = $customerBuilder
4 ->withName('Fran', 'Iglesias', 'Gómez')
5 ->withAddress('Piruleta St', '123', '4', '08030', 'Barcelona')
6 ->build();
Pero, por otro lado, gracias a usar el builder podemos ver que existen, al menos, dos conceptos: el nombre del cliente y su dirección. De hecho, en la dirección tendríamos también dos conceptos: la localidad y las señas dentro de esa localidad. En realidad tenemos casos de Data Clump que podríamos resolver con objetos.
Vamos por partes:
Introduciendo objetos
Parece que no, pero manejamos mucha lógica en algo tan simple como un nombre. Veamos por ejemplo:
- En España usamos nombres con dos apellidos, pero en muchos otros países se suele usar un nombre con un único apellido.
- A veces necesitamos usar partes del nombre por separado, como sería el nombre de pila (“Estimada Susana”, “Sr. Pérez”). Otras veces queremos combinarlo de diferentes formas, como podría ser poner el apellido primero, lo que es útil para listados.
- Y, ¿qué pasa si queremos introducir nueva información relacionada con el nombre? Por ejemplo, el tratamiento (Sr./Sra., Estimado/Estimada, etc.).
El nombre del cliente se puede convertir fácilmente a un objeto, lo que retirará cualquier lógica de la “gestión” del nombre de la clase Customer, contribuyendo al Single Responsibility Principle y proporcionándonos un comportamiento reutilizable.
Así que podemos crear un objeto sencillo:
1 class PersonName
2 {
3 private string $name;
4 private string $firstSurname;
5 private string $lastSurname;
6
7 public function __construct(string $name, string $firstSurname, str\
8 ing $lastSurname)
9 {
10 $this->name = $name;
11 $this->firstSurname = $firstSurname;
12 $this->lastSurname = $lastSurname;
13 }
14 }
Ahora bien, una persona tiene que tener un nombre, no tiene sentido tener objetos PersonName que estén vacíos. Para nuestro ejemplo, las reglas son que Name y FirstSurname son obligatorios y no pueden ser un string vacío. LastSurname es opcional.
Una forma bastante bonita de hacer esto es hacer uso de tipos como NonEmptyString y String.
1 class NonEmptyString
2 {
3 private string $value;
4
5 public function __construct(string $value)
6 {
7 if ('' === $value) {
8 throw new InvalidArgumentException('Value must be non-empty\
9 ');
10 }
11 $this->value = $value;
12 }
13
14 public function __toString(): string
15 {
16 return $this->value;
17 }
18 }
1 class String
2 {
3 private string $value;
4
5 public function __construct(string $value)
6 {
7 $this->value = $value;
8 }
9
10 public function __toString(): string
11 {
12 return $this->value;
13 }
14 }
Así que lo podemos representar de la siguiente forma:
1 class PersonName
2 {
3 private NonEmptyString $name;
4 private NonEmptyString $firstSurname;
5 private String $lastSurname;
6
7 public function __construct(string $name, string $firstSurname, ?st\
8 ring $lastSurname = null)
9 {
10 $this->name = new NonEmptyString($name);
11 $this->firstSurname = new NonEmptyString($firstSurname);
12 $this->lastSurname = new String($lastSurname);
13 }
14 }
Por si te lo estabas preguntando, realmente no puedo considerar seriamente PersonName como un value object. No tiene comportamiento de negocio relevante. Quiero decir, es importante gestionar bien el nombre de las personas, pero seguramente no forma parte de la lógica de negocio de tu aplicación. En cualquier caso, es un buen ejemplo para aprender a trabajar con objetos.
Más adelante volveremos sobre este objeto. Ahora vamos a definir varios value objects. De momento, solo me voy a concentrar en los constructores, sin añadir ningún comportamiento, ni siquiera el método equals ya que quiere centrarme en cómo movernos de usar escalares a estos objetos.
Objetos compuestos de otros objetos
Para tratas las direcciones postales haremos algo parecido y crearemos una clase Address para representar las direcciones de los clientes.
Sin embargo, hemos dicho que podríamos introducir un objeto para el concepto de localidad, que incluiría el código postal y la ciudad, pues son datos que van estrechamente relacionados. Obviamente, esto dependerá de nuestro dominio. En algunos casos no nos hará falta esa granularidad porque simplemente queremos disponer de una dirección postal de nuestros clientes para enviar comunicaciones. Pero en otros casos puede ocurrir que nuestro negocio tenga aspectos que dependan de ese concepto, como un servicio cuya tarifa sea función de la ubicación.
1 class Locality
2 {
3 private string $postalCode;
4 private string $locality;
5
6 public function __construct(string $postalCode, string $locality)
7 {
8 $this->isValidPostalCode($postalCode);
9 $this->isValidLocality($locality);
10
11 $this->postalCode = $postalCode;
12 $this->locality = $locality;
13 }
14
15 private function isValidPostalCode(string $postalCode) : void
16 {
17 if (\strlen($postalCode) !== 5 || (int) substr($postalCode, 0, \
18 2) > 52) {
19 throw new InvalidArgumentException('Invalid Postal Code');
20 }
21 }
22
23 private function isValidLocality(string $locality) : void
24 {
25 if ($locality === '') {
26 throw new InvalidArgumentException('Locality should have a \
27 value');
28 }
29 }
Como se puede ver, tendría sentido introducir un objeto PostalCode, porque tiene unas reglas específicas de validación. En este caso, el código postal debe tener 5 caracteres y los dos primeros no pueden ser mayores de 52, que es el número de provincias en España:
1 class PostalCode
2 {
3 private string $postalCode;
4
5 public function __construct(string $postalCode)
6 {
7 $this->isValidPostalCode($postalCode);
8
9 $this->postalCode = $postalCode;
10 }
11
12 private function isValidPostalCode(string $postalCode) : void
13 {
14 if (\strlen($postalCode) !== 5 || (int) substr($postalCode, 0, \
15 2) > 52) {
16 throw new InvalidArgumentException('Invalid Postal Code');
17 }
18 }
19 }
Aparte de eso, debería haber un nombre de localidad, por lo queLocality podría quedar así:
1 class Locality
2 {
3 private PostalCode $postalCode;
4 private NonEmptyString $locality;
5
6 public function __construct(string $postalCode, string $locality)
7 {
8 $this->postalCode = new PostalCode($postalCode);
9 $this->locality = new NonEmptyString($locality);
10 }
11 }
En fin. Volviendo a nuestro problema original de crear un objeto Address podríamos adoptar este enfoque:
1 class Address
2 {
3 private string $street;
4 private string $streetNumber;
5 private string $floor;
6 private Locality $locality;
7
8 public function __construct(string $street, string $streetNumber, ?\
9 string $floor, Locality $locality)
10 {
11 if ('' === $street || '' === $streetNumber) {
12 throw new InvalidArgumentException('Address should include \
13 street and number');
14 }
15 $this->street = $street;
16 $this->streetNumber = $streetNumber;
17 $this->floor = $floor;
18 $this->locality = $locality;
19 }
20 }
Pero como antes hemos definido tipos que nos proporcionan ciertas garantías:
1 class Address
2 {
3 private NonEmptyString $street;
4 private NonEmptyString $streetNumber;
5 private String $floor;
6 private Locality $locality;
7
8 public function __construct(string $street, string $streetNumber, ?\
9 string $floor, Locality $locality)
10 {
11 $this->street = new NonEmptyString($street);
12 $this->streetNumber = new NonEmptyString($streetNumber);
13 $this->floor = new String($floor);
14 $this->locality = $locality;
15 }
16 }
Siempre que un objeto requiere muchos parámetros en su construcción puede ser interesante plantearse si tenemos buenas razones para organizarlos en un objeto, aplicando el principio de co-variación: si cambian juntos, deberían ir juntos. En este caso, $street, $streetNumber y $floor pueden ir juntos, en forma de StreetAddress porque entre los tres componen un concepto útil.
1 class StreetAddress
2 {
3 private NonEmptyString $street;
4 private NonEmptyString $streetNumber;
5 private String $floor;
6
7 public function __construct(string $street, string $streetNumber, ?\
8 string $floor)
9 {
10 $this->street = new NonEmptyString($street);
11 $this->streetNumber = new NonEmptyString($streetNumber);
12 $this->floor = new String($floor);
13 }
14 }
De este modo, Address se hace más simple y ni siquiera tiene que ocuparse de validar nada:
1 class Address
2 {
3 private StreetAddress $streetAddress;
4 private Locality $locality;
5
6 public function __construct(StreetAddress $streetAddress, Locality \
7 $locality)
8 {
9 $this->streetAddress = $streetAddress;
10 $this->locality = $locality;
11 }
12 }
En resumidas cuentas, a medida que reflexionamos sobre los conceptos del dominio podemos percibir la necesidad de trasladar esa reflexión al código de una forma más articulada y precisa. Pero como hemos señalado antes todo depende de las necesidades de nuestro dominio. Lo cierto es que, como veremos a lo largo del artículo, cuanto más articulado tengamos el dominio, vamos a tener más capacidad de maniobra y muchísima más coherencia.
Usando los objetos
Volvamos a Customer. De momento, el hecho de introducir una serie de objetos no afecta para nada al código que tengamos, por lo que podríamos estar creando cada uno de ellos, mezclando en el proyecto y desplegando sin afectar de ningún modo a la funcionalidad existente. Simplemente, hemos añadido clases a nuestra base de código y ahí están: esperando a ser utilizadas.
En este caso, tener a CustomerBuilder nos viene muy bien, pues encapsula la compleja construcción de Customer, aislándola del resto del código. Podremos refactorizar Customer sin afectar a nadie. Empezaremos por el nombre:
1 class Customer
2 {
3 private string $id;
4 private PersonName $personName;
5 private string $street;
6 private string $streetNumber;
7 private string $floor;
8 private string $postalCode;
9 private string $city;
10
11 public function __construct(
12 string $id,
13 PersonName $personName,
14 string $street,
15 string $streetNumber,
16 string $floor,
17 string $postalCode,
18 string $city
19 ) {
20 $this->id = $id;
21 $this->personName = $personName;
22 $this->street = $street;
23 $this->streetNumber = $streetNumber;
24 $this->floor = $floor;
25 $this->postalCode = $postalCode;
26 $this->city = $city;
27 }
28
29 public function fullName(): string
30 {
31 return $this->personName->fullName();
32 }
33
34 public function address(): string
35 {
36 $address = $this->street . ', ' . $this->streetNumber;
37
38 if ($this->floor) {
39 $address .= ' '. $this->floor;
40 }
41
42 $address .= $this->postalCode. '-'.$this->city;
43
44 return $address;
45 }
46 }
El constructor ya es un poco más simple. Además, el método fullName puede delegarse al disponible en el objeto PersonName, que se puede ocupar cómodamente de cualquier variante o formato particular que necesitemos a lo largo de la aplicación.
1 class PersonName
2 {
3 private NonEmptyString $name;
4 private NonEmptyString $firstSurname;
5 private String $lastSurname;
6
7 public function __construct(string $name, string $firstSurname, ?st\
8 ring $lastSurname = null)
9 {
10 $this->name = new NonEmptyString($name);
11 $this->firstSurname = new NonEmptyString($firstSurname);
12 $this->lastSurname = new String($lastSurname);
13 }
14
15 public function fullName(): string
16 {
17 $fullName = (string)$this->name . ' ' . (string)$this->firstSur\
18 name;
19
20 if (!$this->lastSurname->empty() {
21 $fullName .= ' ' . $this->lastSurname;
22 }
23
24 return $fullName;
25 }
26 }
Como podemos ver, los objetos atraen comportamiento. Si necesitásemos el nombre en un formato apto para listas podríamos hacer lo siguiente:
1 class PersonName
2 {
3 private NonEmptyString $name;
4 private NonEmptyString $firstSurname;
5 private String $lastSurname;
6
7 public function __construct(string $name, string $firstSurname, ?st\
8 ring $lastSurname = null)
9 {
10 $this->name = new NonEmptyString($name);
11 $this->firstSurname = new NonEmptyString($firstSurname);
12 $this->lastSurname = new String($lastSurname);
13 }
14
15 public function fullName(): string
16 {
17 return (string)$this->name .' ' . $this->surname();
18 }
19
20 public function listName(): string
21 {
22 return $this->surname() . ', ' . (string)$this->name;
23 }
24
25 public function surname(): string
26 {
27 $surname = (string)$this->firstSurname;
28
29 if (!$this->lastSurname->empty()) {
30 $surname .= ' ' . $this->lastSurname;
31 }
32
33 return $surname;
34 }
35 }
Como tenemos un Builder que encapsula la construcción de Customer, lo que hacemos es modificar esa construcción de acuerdo al nuevo diseño:
1 class CustomerBuilder
2 {
3 private string $id;
4 private PersonName $personName;
5 private string $street;
6 private string $streetNumber;
7 private string $floor;
8 private string $postalCode;
9 private string $city;
10
11 public function withName(string $name, string $firstSurname, ?strin\
12 g $lastSurname) : self
13 {
14 $this->personName = new PersonName($name, $firstSurname, $lastS\
15 urname);
16
17 return $this;
18 }
19
20 public function withAddress(string $street, string $streetNumber, s\
21 tring $floor, string $postalCode, string $city): self
22 {
23 $this->street = $street;
24 $this->streetNumber = $streetNumber;
25 $this->floor = $floor;
26 $this->postalCode = $postalCode;
27 $this->city = $city;
28
29 return $this;
30 }
31
32 public function build() : Customer
33 {
34 return new Customer(
35 $this->id,
36 $this->personName,
37 $this->street,
38 $this->streetNumber,
39 $this->floor,
40 $this->postalCode,
41 $this->city
42 );
43 }
44 }
Fíjate que he dejado el método withName() tal y como estaba. De esta forma, no cambio la interfaz pública de CustomerBuilder, como tampoco cambia la de Customer salvo en el constructor, y el código que lo usa no se enterará del cambio. En otras palabras, el ejemplo anterior funcionará exactamente igual:
1 $customerBuilder = new CustomerBuilder();
2
3 $customer = $customerBuilder
4 ->withName('Fran', 'Iglesias', 'Gómez')
5 ->withAddress('Piruleta St', '123', '4', '08030', 'Barcelona')
6 ->build();
Por supuesto, haríamos lo mismo con el objeto Address. Por tanto, así quedará Customer:
1 class Customer
2 {
3 private $id;
4 private PersonName $personName;
5 private Address $address;
6
7 public function __construct(
8 string $id,
9 PersonName $personName,
10 Address $address
11 ) {
12 $this->id = $id;
13 $this->personName = $personName;
14 $this->address = $address;
15 }
16
17 public function fullName(): string
18 {
19 return $this->personName->fullName();
20 }
21
22 public function address(): string
23 {
24 return $this->address->full();
25 }
26 }
El método full en Address queda como sigue:
1 class Address
2 {
3 private StreetAddress $streetAddress;
4 private Locality $locality;
5
6 public function __construct(StreetAddress $streetAddress, Locality \
7 $locality)
8 {
9 $this->streetAddress = $streetAddress;
10 $this->locality = $locality;
11 }
12
13 public function full(): string
14 {
15 return (string)$this->streetAddress . ' ' . (string)$this->loca\
16 lity;
17 }
18 }
En este caso necesitaremos:
1 class StreetAddress
2 {
3
4 private NonEmptyString $street;
5 private NonEmptyString $streetNumber;
6 private String $floor;
7
8 public function __construct(string $street, string $streetNumber, ?\
9 string $floor)
10 {
11 $this->street = new NonEmptyString($street);
12 $this->streetNumber = new NonEmptyString($streetNumber);
13 $this->floor = new String($floor);
14 }
15
16 public function __toString(): string
17 {
18 $fullAddress = (string)$this->street . ' ' . (string)$this->str\
19 eetNumber;
20
21 if (!$this->floor->empty()) {
22 $fullAddress .= ', '. $this->floor;
23 }
24
25 return $fullAddress;
26 }
27 }
Y también:
1 class PostalCode
2 {
3 private string $postalCode;
4
5 public function __construct(string $postalCode)
6 {
7 $this->isValidPostalCode($postalCode);
8
9 $this->postalCode = $postalCode;
10 }
11
12 private function isValidPostalCode(string $postalCode) : void
13 {
14 if (\strlen($postalCode) !== 5 || (int) substr($postalCode, 0, \
15 2) > 52) {
16 throw new InvalidArgumentException('Invalid Postal Code');
17 }
18 }
19
20 public function __toString(): string
21 {
22 return $this->postalCode;
23 }
24 }
Así como:
1 class Locality
2 {
3 private PostalCode $postalCode;
4 private NonEmptyString $locality;
5
6 public function __construct(string $postalCode, string $locality)
7 {
8 $this->postalCode = new PostalCode($postalCode);
9 $this->locality = new NonEmptyString($locality);
10 }
11
12 public function __toString(): string
13 {
14 return (string)$this->postalCode .'-'.(string)$this->locality;
15 }
16 }
Del mismo modo que antes, modificaremos CustomerBuilder para utilizar los nuevos objetos:
1 class CustomerBuilder
2 {
3 private $personName;
4 private $address;
5
6 public function withName(string $name, string $firstSurname, ?strin\
7 g $lastSurname) : self
8 {
9 $this->personName = new PersonName($name, $firstSurname, $lastS\
10 urname);
11
12 return $this;
13 }
14
15 public function withAddress(string $street, string $streetNumber, s\
16 tring $floor, string $postalCode, string $city) : self
17 {
18 $locality = new Locality($postalCode, $city);
19 $streetAddress = new StreetAddress($street, $streetNumber, $flo\
20 or);
21
22 $this->address = new Address($streetAddress, $locality);
23
24 return $this;
25 }
26
27 public function build() : Customer
28 {
29 return new Customer(
30 $this->id,
31 $this->personName,
32 $this->address
33 );
34 }
35 }
Y ya está, hemos hecho este cambio sin tener que tocar en ningún lugar más del código. Obviamente, tener un Builder de Customer nos ha facilitado muchos las cosas. En general, para hacer este tipo de refactorizaciones, es útil tener alternativas al constructor canónico, como el propio Builder.
Beneficios
El beneficio más evidente es que las clases importantes del dominio como Customer, quedan mucho más compactas. Hemos podido reducir ocho propiedades a dos, cumpliendo la regla de Calisthenics. Y, además, son conceptos relevantes dentro de Customer.
Por otro lado, Customer delega todos los detalles a esos objetos. Dicho de otro modo, Customer no tiene que saber cómo se da formato a un nombre o a una dirección. Simplemente, cuando se lo piden entrega el nombre o la dirección formateados. Asimismo, cualquier otro objeto que usase PersonName o Address, lo hará de la misma manera.
Otra cosa interesante es que los cambios que necesitemos en el comportamiento de estas propiedades pueden aplicarse sin tocar el código de la clase, modificando o cambiando los objetos, con lo cual el nuevo comportamiento se extenderá a todas las partes de la aplicación que lo utilicen.
Sin embargo, nuestro dominio tiene ahora muchísima flexibilidad y capacidad de cambio.
Sería bastante fácil, por ejemplo, dar soporte a los múltiples formatos de dirección postal que se usan en todo el mundo, de modo que nuestro negocio está mejor preparado para expandirse internacionalmente, puesto que solo tendríamos que introducir una interfaz y nuevos formatos a medida que los necesitemos, sin tener que cambiar el core del dominio. Puede sonar exagerado, pero estos pequeños detalles pueden ser un dolor de cabeza enorme si seguimos el modelo con el que empezamos. Algo tan pequeño puede ser la diferencia entre un código y un negocio que escale fácilmente o no.
Resumen del capítulo
Encapsular tipos primitivos en objetos es una receta de éxito para lograr código más simple y, a la vez, ganar en consistencia y flexibilidad. El código tiene que preocuparse menos por conocer el detalle de los datos, centrándose en cómo interactúan los objetos y colaboran entre ellos para lograr el propósito de la aplicación.