Objetos inmutables (immutability)
Un compañero me enseña un fallo que no hay manera de reproducir a mano. En la ficha de una referencia aparecen cuarenta unidades disponibles al abrir la pantalla y veintiocho al terminar de pintarla, sin que nadie haya vendido nada por el camino. El registro de movimientos no acusa ninguna salida. La base de datos sigue diciendo cuarenta. Y aun así el número cambia solo.
La clase que hay detrás es de las que no levantan ninguna sospecha.
final class Stock
{
private function __construct(
private 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 function withdraw(int $units): void
{
$this->units -= $units;
}
public function units(): int
{
return $this->units;
}
}
Tiene su validación en el constructor, su constructor con nombre y un método corto que hace lo que promete. El problema no está en lo que hace withdraw, está en sobre qué lo hace. Resta unidades al objeto en el que vive, así que el objeto de después ya no es el de antes aunque siga llamándose igual.
Lo que de verdad le pasas a otra clase
El código de la pantalla era más o menos este.
$available = Stock::of(40);
$reservation->apply($available);
echo $available->units();
Tres líneas sin ningún truco. Creo unas existencias de cuarenta unidades, dejo que la reserva haga su trabajo y pregunto por las unidades de la variable que tengo yo. Lo que sale por pantalla son veintiocho.
Aquí conviene recordar qué guarda de verdad una variable cuando le asignas un objeto. No guarda el objeto, guarda la forma de llegar hasta él. Al pasar $available a apply no le he entregado una foto de las existencias, le he entregado la dirección de una caja que el otro puede abrir. Y la abre, porque por dentro llama a withdraw. Mi variable no ha cambiado en ningún momento, sigue apuntando al mismo sitio que al principio. Lo que ha cambiado es lo que hay guardado en ese sitio.
Lo que se ha roto no es un número, es la posibilidad de razonar sobre el código leyéndolo. Esas tres líneas no dicen en ninguna parte que las existencias vayan a moverse, y se mueven. Para saber si $available vale lo mismo antes y después tengo que bajar a leer apply, y dentro de apply lo que llame apply, y así hasta el fondo. Un objeto que puede cambiar por dentro convierte en sospechoso a cada método al que se lo pasas.
Devolver en lugar de cambiar
El arreglo cabe en una línea y es de los que cambian la forma de pensar. En vez de modificar el objeto, el método construye uno nuevo con el resultado y lo devuelve.
public function withdraw(int $units): self
{
return new self($this->units - $units);
}
Fíjate primero en la firma, porque ya cuenta media historia. Antes devolvía void, y un método que no devuelve nada solo puede estar ahí por lo que toca. Ahora devuelve self, y de un método que devuelve algo esperas un resultado en la mano, no un efecto a tus espaldas. El punto de llamada tiene que decidir qué hace con lo que recibe, y esa decisión, que antes estaba escondida, pasa a estar escrita.
$available = Stock::of(40);
$afterReservation = $available->withdraw(12);
Ahora hay dos objetos, cada uno con su valor y su nombre. $available vale cuarenta y va a seguir valiendo cuarenta se lo pases a quien se lo pases. $afterReservation vale veintiocho desde el instante en que nace. Y de propina, como la creación vuelve a pasar por el constructor, la regla de que un stock nunca es negativo protege también a la cifra nueva sin repetirla en withdraw.
El nombre también pide un repaso. withdraw suena a orden, a "quítame doce unidades de ahí", y esa entonación era coherente cuando el método mutaba. Si lo que hace es fabricar un valor nuevo, el vocabulario tira hacia nombres como minus, plus o withUnits, que suenan a cálculo y no a mandato.
Cerrar la clase de verdad
Que un método devuelva una instancia nueva está muy bien, pero la puerta sigue entornada mientras la propiedad se pueda escribir. Basta con que alguien añada mañana un setUnits con la mejor de las intenciones para volver al punto de partida. En PHP moderno eso se cierra con readonly.
private function __construct(
private readonly int $units,
) {
if ($units < 0) {
throw new InvalidArgumentException(
'Stock can never be negative.'
);
}
}
Una propiedad readonly solo admite un valor, y solo dentro del ámbito donde se declaró. Ni siquiera la propia clase puede volver a escribirla después. La inmutabilidad deja de ser una promesa que el equipo mantiene por buena voluntad y pasa a ser algo que el lenguaje impide, que es otra cosa el día que entra gente nueva al proyecto. El final remata la faena, porque sin él una subclase puede abrir por detrás lo que tú cerraste por delante.
La grieta de lo que no son números
readonly protege el buzón, no lo que hay dentro del buzón. Con un entero eso da igual, porque un entero es su valor. Con un objeto la cosa cambia. Una reserva puede guardar su DateTime en una propiedad de solo lectura y encontrarse igualmente con otra fecha, porque cualquiera que tenga ese mismo objeto en la mano puede llamar a modify y moverlo dos meses. La salida no es vigilar quién guarda referencias, es usar DateTimeImmutable. Con las colecciones pasa lo mismo, el array de líneas viaja como copia y nadie te añade líneas por su cuenta, pero los objetos de dentro siguen siendo los tuyos y se pueden cambiar uno a uno. Un objeto solo es inmutable de verdad si todo lo que guarda dentro también lo es.
Los tests que lo dejan por escrito
Todo esto se explica en una reunión y se olvida en dos semanas. Un test, en cambio, se queda, y el de un objeto inmutable mira algo que un test corriente no suele mirar, porque aquí lo importante no es solo el resultado, es que haya dos objetos y no uno.
public function testMinusReturnsADifferentInstance(): void
{
$available = Stock::of(40);
$afterReservation = $available->minus(12);
self::assertNotSame($available, $afterReservation);
self::assertInstanceOf(Stock::class, $afterReservation);
self::assertSame(40, $available->units());
self::assertSame(28, $afterReservation->units());
}
La primera aserción es la que da nombre al test. assertNotSame no compara valores, compara identidad, y responde justo a la pregunta que importa. Lo que tengo en las manos son dos instancias distintas de la misma clase, no dos nombres para el mismo objeto. El día que alguien vuelva a poner $this->units -= $units dentro del método, esa línea se pone roja al instante.
Conviene no confundirla con assertEquals, que compara el contenido y te diría que dos stocks de veintiocho unidades son iguales aunque sean objetos separados. Eso es exactamente lo que quieres afirmar sobre el valor devuelto y exactamente lo que no quieres afirmar sobre la identidad. Las dos aserciones dicen cosas distintas y las dos hacen falta.
La aserción sobre $available, la que dice que sigue valiendo cuarenta, parece la más tonta de todas y es la que más veces me ha salvado. Documenta la promesa que le haces a quien use la clase. Llames a lo que llames con este objeto, el objeto que tú tienes no se mueve. Y de ahí sale casi solo el test que cierra el círculo, el que reproduce el fallo del principio.
public function testApplyingAReservationLeavesTheStockUntouched(): void
{
$available = Stock::of(40);
$this->reservations->apply($available);
self::assertSame(40, $available->units());
}
Ese test no habla de Stock, habla de la frontera entre dos clases. Dice que pasarle tus existencias a otro no es cederle el derecho a cambiarlas. Cuando falla, te avisa de que alguien ha vuelto a abrir la caja.
La ventaja que de verdad importa
Un objeto inmutable no calcula nada mejor que uno mutable. Lo que hace es quitarte una pregunta de encima. Cuando lees una línea que le pasa un objeto a otra clase ya no tienes que preguntarte qué le harán dentro, porque no le pueden hacer nada. El valor que leíste arriba es el que sigue habiendo abajo, y esa certeza vale más cuantas más manos y más capas atraviesa el mismo dato.
Es también la razón por la que en DDD los objetos de valor se diseñan así por defecto. Un importe, una cantidad o una fecha de entrega no son cosas que cambien, son cosas que se sustituyen por otras. Cincuenta euros no se convierten en sesenta, lo que pasa es que ahora hay otro importe distinto que vale sesenta. Cuando el código refleja eso, desaparece el rastreo mental de quién modificó qué y en qué orden, que es de lo más caro que se paga al mantener un sistema vivo.
Escribir return new self($this->units - $units) en lugar de $this->units -= $units no hace el programa más rápido ni más corto. Hace que puedas fiarte del objeto que ya tienes en la mano.