Constructores con nombre (named constructors)
Abro una clase que no he escrito yo y me encuentro con esta línea.
$available = new Stock(40);
Cuarenta. De acuerdo. La palabra new me dice que estoy creando unas existencias y el número me dice cuántas, pero hay algo que se calla. No sé si esa clase acepta cualquier entero, no sé si cuarenta es una cantidad legítima, no sé si alguien podría haber escrito un número negativo sin que nadie protestara. El nombre de la clase describe el molde. No describe la intención de quien creó el objeto ni las reglas que lo hacen válido. Y esa distancia entre lo que el código hace y lo que el código cuenta es justo el hueco por donde se cuelan los errores tontos.
PHP tampoco ayuda demasiado. Una clase tiene un solo constructor. Uno. No puedes ofrecer varias maneras de construir con nombres distintos, como harías en otros lenguajes, así que esa única puerta termina cargando con todas las formas posibles de crear el objeto. Con el tiempo se llena de parámetros opcionales y de condiciones que intentan adivinar qué querías en cada caso.
Que la creación hable tu idioma
La propuesta de los constructores con nombre es casi de sentido común. En lugar de obligar a todo el mundo a pasar por new, ofreces métodos estáticos cuyo nombre dice para qué sirven. Por dentro pueden seguir haciendo lo de siempre. Lo que cambia es que el punto donde creas el objeto empieza a leerse como una frase del negocio y no como un detalle técnico.
Imagina un almacén. Hablando con quien lo gestiona jamás dirás "voy a instanciar unas existencias con valor cuarenta". Dirás que hay cuarenta unidades de una referencia, o que una referencia está agotada. Llevemos ese vocabulario al código.
final class Stock
{
public function __construct(
private readonly int $units,
) {
if ($units < 0) {
throw new InvalidArgumentException(
'Stock can never be negative.'
);
}
}
public static function of(int $units): self
{
return new self($units);
}
public static function soldOut(): self
{
return new self(0);
}
}
Y ahora el punto de llamada respira.
$available = Stock::of(40);
$outOfStock = Stock::soldOut();
Fíjate en soldOut. No recibe parámetros y aun así es el más interesante, porque le pone nombre a un estado que en el negocio tiene identidad propia. Antes ese concepto vivía escondido dentro de un cero suelto que había que saber interpretar. Ahora es una palabra que cualquiera reconoce a la primera. El código ha empezado a usar la misma palabra que usa el negocio, y ese es justo el sitio donde esa palabra de verdad evita malentendidos.
Una puerta para cada necesidad
Hay una ventaja que va más allá de ponerle un nombre bonito a la creación. Una misma clase puede tener tantos constructores con nombre como formas legítimas tengas de crear el objeto, y cada uno pide justo los datos que tienen sentido en su contexto. La mercancía no siempre entra al almacén contada unidad a unidad. Muchas veces llega en cajas, y lo que sabes es cuántas cajas hay y cuántas unidades trae cada una. Pues le añades su propia puerta a la clase.
public static function fromPackages(int $packages, int $unitsPerPackage): self
{
return new self($packages * $unitsPerPackage);
}
Y en el sitio donde se usa, cada situación se cuenta con sus propias palabras.
$available = Stock::of(40);
$received = Stock::fromPackages(4, 12);
$outOfStock = Stock::soldOut();
Con el único constructor que te da PHP tendrías que meter estas tres formas en la misma firma, a base de parámetros opcionales y de algún if que intente adivinar si le has pasado unidades sueltas o cajas. Acabas con un constructor que lo hace todo y no explica nada. Con constructores con nombre, en cambio, cada necesidad tiene su entrada, con los parámetros que le tocan y un nombre que dice para qué está. Y hay un detalle que no es menor. Las tres puertas acaban llamando al mismo constructor, así que la regla de que un stock nunca es negativo vive en un solo sitio y las protege a todas por igual, den la cifra que den.
Cerrar la puerta de atrás
Queda un cabo suelto. Mientras el constructor siga siendo público, cualquiera puede ignorar tus métodos con nombre y volver a escribir new Stock(40) por su cuenta. Y no porque eso cree un objeto inválido, la validación te protege igual. El problema es de coherencia. Has diseñado unas puertas de entrada que hablan el idioma del negocio, pero si la puerta genérica sigue abierta, tarde o temprano media base de código entrará por ella y perderás todo lo ganado. La solución es sencilla. Conviertes el constructor en privado.
private function __construct(
private readonly int $units,
) {
if ($units < 0) {
throw new InvalidArgumentException(
'Stock can never be negative.'
);
}
}
Con el constructor en privado, el único código capaz de invocar new es el que vive dentro de la propia clase. Desde fuera ya no queda ningún atajo, solo quedan tus constructores con nombre. Lo que consigues es que no exista una sola forma de crear un Stock que se salte las palabras del negocio. La creación entera queda escrita en tu idioma, sin fugas por la puerta de atrás.
Un detalle que se agradece
Cada uno de estos métodos termina llamando a new, y escribir el nombre de la clase en cada retorno es repetitivo y frágil. El día que renombres la clase tendrás que ir corrigiendo ocurrencia por ocurrencia. Para eso está self, que apunta a la propia clase sin nombrarla.
public static function of(int $units): self
{
return new self($units);
}
La misma idea, también para los errores
Una excepción es un objeto como cualquier otro. Tiene un constructor, recibe datos y se crea con new. Y como cualquier otro objeto, gana muchísimo cuando le pones nombre a la manera de fabricarla. Piensa en la operación de retirar unidades del almacén. Si alguien intenta sacar más unidades de las que hay disponibles, lo suyo es lanzar una excepción que se entienda por sí sola.
final class InsufficientStock extends RuntimeException
{
public static function forWithdrawal(int $requested, int $available): self
{
return new self(sprintf(
'Requested %d units but only %d are available.',
$requested,
$available,
));
}
}
Y en el lugar donde salta, la lectura cambia por completo.
if ($requested > $this->units) {
throw InsufficientStock::forWithdrawal($requested, $this->units);
}
Comparado con un throw new RuntimeException('...') con el mensaje montado ahí mismo a mano, esto reúne en un único punto cómo se construye ese error y con qué datos. El día que quieras añadir un código interno, registrar algo o afinar el texto, tienes un solo lugar donde tocar. Y quien lee el throw entiende qué ha pasado sin bajar a leer las tripas de la clase.
La ventaja que de verdad importa
Un constructor con nombre hace que crear un objeto se parezca a hablar del negocio en lugar de a manejar una herramienta. Stock::soldOut() se entiende sin saber PHP. Lo entiende quien escribe el código y lo entiende quien gestiona el almacén, porque los dos usan esa misma palabra para referirse a lo mismo.
A eso, en DDD, se le llama lenguaje ubicuo: que el código y las personas del negocio hablen con las mismas palabras. Un new con números y cadenas sueltas te obliga a traducir por dentro qué significa cada parámetro. Un constructor con nombre ya viene escrito en el idioma del negocio, y esa traducción desaparece. Por eso, antes de preguntarte si un objeto merece un constructor con nombre, dalo por hecho y dedica el esfuerzo a elegir el nombre que mejor cuenta lo que está pasando.
Escribir Stock::soldOut() en lugar de new Stock(0) no hace el programa ni más rápido ni más corto. Lo hace más fácil de leer.