Salvador Llaberia · Software Architect ← Todos los artículos

Tell Don't Ask (dile, no preguntes)

Portada del artículo Tell Don't Ask (dile, no preguntes)

Imagina una aplicación que maneja presupuestos de venta. Un presupuesto se puede aceptar mientras siga vigente y nadie lo haya aceptado antes, y esas dos condiciones están escritas en el caso de uso que atiende el botón de aceptar. Imagina ahora que hay que publicar un endpoint para que el cliente pueda aceptarlo también desde la tienda online. Quien lo escribe mira el caso de uso que ya existe, copia la idea y se deja una de las dos condiciones por el camino. Un día entra por ahí la aceptación de un presupuesto caducado hace once días.

Es un escenario inventado, pero de los que se repiten con mucha facilidad. Lo interesante no es el descuido, es dónde vivía la regla. Vivía en la capa de aplicación, repetida en cada sitio que quería aceptar un presupuesto. Y una regla que hay que acordarse de repetir es una regla que un día se olvida.

El caso de uso que pregunta

El servicio de partida sería algo así, y no tiene mala pinta a primera vista.

final class AcceptQuote
{
    public function __construct(
        private QuoteRepository $quotes,
    ) {
    }

    public function __invoke(string $quoteId): void
    {
        $quote = $this->quotes->ofIdOrFail(QuoteId::fromString($quoteId));

        if ($quote->status() !== QuoteStatus::PENDING) {
            return;
        }

        if ($quote->expiresAt() < new DateTimeImmutable()) {
            return;
        }

        $quote->setStatus(QuoteStatus::ACCEPTED);
        $quote->setAcceptedAt(new DateTimeImmutable());

        $this->quotes->save($quote);
    }
}

El servicio pregunta primero y actúa después. Le saca el estado al presupuesto, le saca la fecha de caducidad, decide él si eso se puede aceptar y, si le parece que sí, le escribe los campos a mano. El presupuesto no participa en la decisión, solo pone los datos y se deja tocar.

Y se deja tocar porque la clase que hay detrás está escrita para eso.

final class Quote
{
    private QuoteId $id;
    private QuoteStatus $status;
    private DateTimeImmutable $expiresAt;
    private ?DateTimeImmutable $acceptedAt = null;

    public function status(): QuoteStatus
    {
        return $this->status;
    }

    public function setStatus(QuoteStatus $status): void
    {
        $this->status = $status;
    }

    public function expiresAt(): DateTimeImmutable
    {
        return $this->expiresAt;
    }

    public function setAcceptedAt(DateTimeImmutable $acceptedAt): void
    {
        $this->acceptedAt = $acceptedAt;
    }
}

Esto es una entidad anémica de manual. Cuatro propiedades y ocho métodos que no hacen nada más que leerlas y escribirlas. Léela entera y no encontrarás una sola línea que hable de presupuestos, solo campos que entran y salen. Es la misma información que hay en la fila de la base de datos, envuelta en una clase y con tipos de PHP delante.

Fíjate en lo que permite esa pareja de setters. Cualquiera puede llamar a setStatus y dejar aceptado un presupuesto caducado, o marcar la fecha de aceptación sin tocar el estado y dejarlo pendiente y aceptado a la vez. Nada en la clase lo impide, porque la clase no sabe que eso es imposible. Lo saben los servicios, cada uno por su cuenta.

Ahí está el problema entero. Saber cuándo un presupuesto se puede aceptar es la definición misma de qué es un presupuesto en este negocio, y esa definición está escrita fuera de la clase Quote. La clase guarda los datos y la regla vive en otra parte, en tantas otras partes como servicios quieran aceptar presupuestos. El día que añades el tercero, ya te has olvidado de algo.

Decirle lo que quieres

Tell Don't Ask consiste en cambiar el orden de las cosas. Dejas de preguntar para decidir tú y actuar después, y pasas a dar la orden para que sea la entidad quien la ejecute. Ella es la única que conoce su estado, así que es la única que puede saber si esa orden tiene sentido.

El cambio consigue dos cosas a la vez. La entidad deja de ser anémica y se vuelve rica, porque recupera el comportamiento que le habían quitado y vuelve a tener algo que decir sobre lo que le pasa. Y queda blindada, porque si nadie le escribe los campos desde fuera nadie puede dejarla en un estado que el negocio no admite. Un presupuesto que se acepta a sí mismo no puede quedarse aceptado y caducado a la vez. Uno al que le colocan el estado desde otra clase sí, y basta con que alguien escriba las asignaciones en distinto orden o se salte una comprobación.

Aplicado aquí, el cambio consiste en mover las dos preguntas dentro de la entidad y darle al método un nombre que exista en el vocabulario del negocio.

final class Quote
{
    use RecordsDomainEvents;

    private function __construct(
        private readonly QuoteId $id,
        private QuoteStatus $status,
        private readonly DateTimeImmutable $expiresAt,
        private ?DateTimeImmutable $acceptedAt = null,
    ) {
    }

    public static function issue(QuoteId $id, DateTimeImmutable $expiresAt): self
    {
        return new self($id, QuoteStatus::PENDING, $expiresAt);
    }

    public function accept(DateTimeImmutable $at): void
    {
        if ($this->status !== QuoteStatus::PENDING) {
            throw QuoteCannotBeAccepted::becauseItIs($this->status);
        }

        if ($this->expiresAt < $at) {
            throw QuoteCannotBeAccepted::becauseItExpiredOn($this->expiresAt);
        }

        $this->status = QuoteStatus::ACCEPTED;
        $this->acceptedAt = $at;

        $this->record(new QuoteWasAccepted($this->id, $at));
    }

    public function status(): QuoteStatus
    {
        return $this->status;
    }
}

Es la misma clase de antes y no se parece en nada. Los dos setters han desaparecido, y con ellos la posibilidad de dejar el presupuesto en un estado que el negocio no reconoce. El único camino para nacer es issue, que es el verbo que usa el negocio para decir que se emite un presupuesto y que garantiza que ninguno empieza en un estado que no sea el suyo, y el único camino para cambiar de estado es accept, que antes de mover nada comprueba lo que hay que comprobar.

Mira las dos asignaciones del final. Ahora ocurren juntas o no ocurre ninguna, porque están dentro del mismo método y detrás de las mismas condiciones. En la versión anémica eran dos llamadas separadas desde fuera, y entre una y otra el objeto pasaba por un estado que no debería existir. Eso es a lo que me refiero con blindar la entidad. No es esconder los datos por gusto, es que las transiciones válidas sean las únicas que el código permite escribir.

El getter que queda es el que sirve para pintar el estado en una pantalla o para decidir un color en un listado, no para que nadie tome decisiones sobre el presupuesto. Y el evento de dominio se registra justo donde tiene que registrarse, en el único punto del programa que sabe con certeza que la aceptación acaba de ocurrir. Los eventos de dominio dan para un artículo entero y lo escribiré más adelante, así que de momento quédate con esa idea, que el sitio donde se toma la decisión es el mismo sitio donde nace el evento.

Con la entidad así, el caso de uso se queda en lo que siempre debió ser.

public function __invoke(string $quoteId): void
{
    $quote = $this->quotes->ofIdOrFail(QuoteId::fromString($quoteId));

    $quote->accept($this->clock->now());

    $this->quotes->save($quote);
}

Tres líneas que se leen como una frase. Busca el presupuesto, dile que se acepte, guárdalo. No queda ni una decisión de negocio en el servicio, y eso significa que el endpoint de la tienda, el de la pantalla interna y un hipotético consumidor de cola que integre el ERP harían exactamente lo mismo sin que nadie tenga que acordarse de nada.

Hay un detalle en la firma que parece menor y no lo es. El instante entra por parámetro en lugar de fabricarse dentro con new DateTimeImmutable(). Una entidad que consulta el reloj del sistema depende a escondidas de algo que no controla, y eso se paga al testearla. Pasándoselo, el dominio se queda con la regla (un presupuesto caducado no se acepta) y deja fuera el detalle de qué hora es, que es infraestructura.

Por defecto va a funcionar

La parte que más cuesta de este principio no es mover el if, es aceptar que ya no hay valor de retorno que consultar. Fíjate en que accept devuelve void y en que el caso de uso no comprueba nada después de llamarlo. La tentación de escribir esto otro es fuerte.

if (!$quote->canBeAccepted()) {
    return;
}

$quote->accept($now);

Eso es volver al principio con mejores modales. Sigues preguntando antes de ordenar, solo que ahora la pregunta tiene un nombre más bonito. Además abres una grieta bastante fea, porque entre la respuesta y la orden hay un hueco, y en un sistema con concurrencia ese hueco se llena de sorpresas. Si dos peticiones preguntan a la vez, las dos oyen que sí.

Lo que se escribe en su lugar es la orden, sin red. El supuesto por defecto es que va a funcionar, porque el camino normal de un presupuesto es que se acepte, y ese camino merece leerse recto, sin condicionales que lo interrumpan. Cuando no se pueda, la entidad lanza una excepción de dominio y el flujo se corta solo, que es justo lo que quieres cuando la operación no tiene sentido.

Esa excepción no se captura donde nace ni en cada llamada. Sube hasta la frontera del sistema, donde el adaptador que atendió la petición decide cómo se cuenta hacia fuera. En una API se traduce a un código de estado, en un consumidor de mensajes a un reintento o a una cola de errores, en la consola a un mensaje y un código de salida. Un único punto por adaptador, no uno por caso de uso.

try {
    ($this->acceptQuote)($request->get('quoteId'));
} catch (QuoteCannotBeAccepted $e) {
    return new JsonResponse(['error' => $e->getMessage()], 409);
}

Conviene distinguir dos cosas que se confunden a menudo. Una excepción de dominio no es un error del programa, es una manera de decir que la operación que has pedido no cabe en las reglas del negocio. Por eso tiene nombre propio, QuoteCannotBeAccepted, y por eso el mensaje lo compone la propia entidad con los constructores con nombre que ves arriba. El día que alguien lea el log verá "el presupuesto caducó el 29 de julio" y no un InvalidArgumentException genérico disparado a saber desde dónde.

No siempre hace falta llegar a la excepción. Si en tu negocio aceptar dos veces el mismo presupuesto no es un error, la entidad puede decidir que no hay nada que hacer y salir sin ruido. Lo que no cambia es quién decide. La decisión sigue estando dentro, y quien llama sigue limitándose a dar la orden.

public function accept(DateTimeImmutable $at): void
{
    if ($this->status === QuoteStatus::ACCEPTED) {
        return;
    }

    // el resto de reglas siguen aquí
}

Lo que gana el dominio

La distancia entre las dos versiones de Quote es exactamente la distancia entre un modelo anémico y uno rico. Lo que en DDD se llaman invariantes, las cosas que un presupuesto tiene que cumplir siempre, en la primera versión quedaban al cuidado de quien pasara por allí, y en la segunda están escritas dentro del objeto que se supone que las cumple. Da igual quién lo tenga en la mano y desde qué capa, las reglas viajan con él.

Al mover la regla dentro pasa algo más profundo que ahorrar duplicación. La clase deja de ser un contenedor de datos y empieza a ser el sitio donde vive el conocimiento del negocio. Si mañana el comercial decide que un presupuesto de más de cinco mil euros necesita el visto bueno de un responsable, sabes exactamente dónde se escribe eso y sabes que se va a aplicar en todos los caminos de entrada a la vez.

El vocabulario también cambia, y esto en DDD no es cosmética. setStatus(QuoteStatus::ACCEPTED) es una frase que no dice nadie en la empresa. "Acepta el presupuesto" la dicen todos los días. Cuando el método se llama accept, el código empieza a parecerse a la conversación que tienes con el negocio, y esa es la única manera realista de que un modelo sobreviva a los años.

La objeción que aparece siempre al quitar los setters es cómo reconstruye entonces el ORM la entidad al leerla de la base de datos. La respuesta tranquilizadora es que no los necesita, porque escribe las propiedades por reflexión sin pasar por ningún método. Y si tu capa de persistencia sí los pide, la salida no es abrir la clase de par en par, es un método de reconstrucción con nombre claro que solo usa el repositorio. Adaptar el mapeo cuesta un rato una vez. Tener una entidad que cualquiera puede dejar en un estado imposible cuesta el resto del proyecto.

Lo que gana la arquitectura

En hexagonal la regla de oro es que el dominio está en el centro y no depende de nada, mientras que los adaptadores dependen de él. Los casos de uso son la capa fina que coordina, buscan por un puerto, dan una orden y persisten por otro puerto. En cuanto un caso de uso empieza a decidir, esa capa fina engorda y la lógica se escapa del centro hacia el borde.

El presupuesto caducado del principio es precisamente eso. La regla se había ido a vivir a la capa de aplicación, y por eso la aceptación pudo entrar por un lado del hexágono sin pasar por ella. Con la regla dentro de la entidad ya no importa por qué lado entres, porque todos los adaptadores acaban llamando al mismo método. Ese es el sentido de tener un centro. No es una capa más para justificar carpetas, es el único sitio donde una regla se escribe una vez y vale para siempre.

Hay un efecto colateral que se nota enseguida en los tests. Un caso de uso que no decide se prueba con dobles muy simples y casi no tiene ramas que cubrir. La entidad, que sí decide, se prueba sin base de datos, sin framework y sin contenedor de dependencias, porque solo necesita construirse y recibir una orden. La lógica más valiosa del sistema pasa a estar en la clase más barata de testear, y eso es exactamente lo que persigue la arquitectura hexagonal.

public function testAnExpiredQuoteCannotBeAccepted(): void
{
    $quote = Quote::issue(
        QuoteId::fromString('9f1c...'),
        new DateTimeImmutable('2026-07-29'),
    );

    $this->expectException(QuoteCannotBeAccepted::class);

    $quote->accept(new DateTimeImmutable('2026-08-09'));
}

Ese test es la regla de negocio escrita en un idioma que la máquina verifica cada vez que alguien toca el código. Fíjate en que no aparece ningún repositorio ni ninguna petición HTTP. Solo un presupuesto, una fecha y una orden.

Lo que gana el código

Desde el punto de vista del clean code esto es encapsulación. Un objeto encapsula cuando puedes usarlo sin saber qué guarda dentro, y eso solo pasa cuando le pides comportamiento en lugar de datos.

De regalo se cumple sola la ley de Demeter, esa que dice que hables solo con tus vecinos directos. El caso de uso ya no encadena $quote->status() para compararlo con nada ni navega hacia dentro del objeto buscando con qué decidir.

Lo importante de eso es lo que el caso de uso deja de saber. Antes conocía el comportamiento del presupuesto, sabía qué estados existen, en cuáles se puede aceptar y qué campos hay que escribir para que la aceptación quede registrada. Ahora sabe una sola cosa, que a un presupuesto se le puede pedir que se acepte. Toda la lógica está delegada, y con ella se ha ido el conocimiento que el servicio tenía sobre las tripas de la entidad.

Ese es el desacoplamiento de verdad, y no consiste en meter interfaces por en medio. Consiste en que cada clase tenga una sola razón para cambiar. Cuando el negocio decida que un presupuesto también se puede aceptar en revisión, se toca accept y nada más, porque ningún caso de uso tenía una opinión sobre eso. Mientras que en la versión anémica cambiar la regla obligaba a repasar todos los servicios que la habían copiado, con la certeza incómoda de que a alguno no vas a llegar. Y desaparece de paso un tipo de duplicación especialmente traicionera, la de la misma condición repetida en sitios que no se parecen entre sí, que es la que nunca encuentras cuando cambia la regla.

También cambia lo que cuesta leer un caso de uso. Antes tenías que reconstruir mentalmente el ciclo de vida del presupuesto a partir de los if repartidos por varios servicios. Ahora abres Quote y ves los cuatro métodos que puede recibir, accept, reject, expire y revise, cada uno con sus condiciones dentro. La clase te cuenta lo que un presupuesto es capaz de hacer, que es una información que antes no estaba escrita en ninguna parte.

Cuándo sí se pregunta

Nada de esto convierte a los getters en pecado. Para pintar una pantalla necesitas leer el estado, para exportar un listado necesitas leer los importes y para responder una consulta de la API necesitas leer casi todo. Preguntar para mostrar es normal y no rompe nada.

La línea está en preguntar para decidir. Si lees el estado de un objeto y lo siguiente que escribes es un if que decide qué hacerle a ese mismo objeto, esa decisión estaba en el sitio equivocado. Ese if es una regla del negocio que se ha escapado de la clase a la que pertenece, y da igual lo bien escrito que esté el servicio que lo contiene.