No quiero convenceros de nada, sólo quiero contar la reflexión que hemos hecho después de muchas líneas de código y muchos refactorings.
Seguro que muchas veces has escuchado que los DTO (Data Transfer Object) deben ser planos, sin lógica de negocio, con tipos básicos. ¿Por qué?
La idea de los DTOs nació con la necesidad de transportar datos entre diferentes sistemas, si en el sistema de origen tengo un tipo de datos y en el sistema de destino no lo tengo, resulta complicado compartir información.
Hace años (muchos) compartir información entre sistema era un dolor de huevos, literalmente. No había UTF, los Mac tenían los bytes de los números al revés, las fechas en diferentes formatos, los decimales con punto y coma, un verdadero caos.
En aquella época se empezó a trabajar con los DTOs para solucionar ese problema. Al trabajar con tipos muy básicos, se pensó que sería mas sencillo comunicar sistemas. Pero aquello no funcionó bien, ¿qué es un tipo básico? Lo mismo Bool no es un tipo básico para algunos sistemas (para MySQL por ejemplo).
Después llegó XML, lento, complicado aunque versátil, pero no quisimos aprender a usarlo.
Mas tarde llegó JSON y magia… podías compartir un objeto con Javascript, Java, PHP, C, etc… era rápido, sencillo. Ya teníamos una forma de compartir con datos básicos.
¿Y por que seguimos usando los DTOs?
¿Te has parado a pensarlo de verdad?¿en serio?
Vamos con DDD
En mi dominio de Pedidos tengo una entidad “Order”.
final class Order
{
public function __construct(
public OrderId $id,
public OrderStatusEnum $status
)
{
}
}
En el sistema de pagos, necesito saber el estado del pedido para emitir la factura o esperar a que esté preparado. Es otro BoundedContext, pero dentro del mismo proyecto.
Tengo dos opciones:
- Accedo al repositorio y recupero el pedido.
- Llamo a una Query que me devuelve un DTO Pedido.
Pero si devuelvo la entidad, estaría acoplando el BoundedContext de Pagos al de Pedidos. Cualquier cambio en Pedidos, afectaría a Pagos. Y además, estaría permitiendo a Pagos hacer cosas con mi dominio Pedido. No quiero que Pagos directamente pueda cambiar nada de mi pedido.
La recomendación general en DDD es usar un DTO.
final class OrderDto
{
public function __construct(
public string $id,
public string $status
)
{
}
}
De esta forma, Pagos y Pedidos están desacoplados. Si hago un refactoring en Order, por ejemplo, cambiar el nombre de la propiedad Status, no afectaría a quien lo consume.
Un poco más tarde, nos piden que hagamos un API de pedidos y como el objeto es simple lo devuelvo en el API.
La hemos cagado
¿Que pasa si necesito modificar el DTO que uso tanto internamente como en el API? No puedo, el API es un contrato que no puedo romper.
O que pasa si necesito devolver algo diferente en API, como la descripción del status en lugar del enum. Tendríamos que modificar el DTO, afectando a Pagos. Estamos acoplando todo el código al DTO.
Aquí Laravel da una solución interesante, un DTO llamado Resource. Que es lo mismo, pero controlando además como se exporta ese objeto a Json.
De esta forma mi OrderResource sería:
final class OrderResource implements JsonSerializable
{
public function __construct(
public string $id,
public string $status
)
{
}
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'status' => $this->status,
];
}
}
De esta forma lo que tenemos es:
- Order. Que uso dentro del Dominio.
- OrderDto. Para compartir entre distintas partes del proyecto.
- OrderResource. Como contrato hacia afuera.
Hasta aquí todo parece lógico, aunque un poco rollo. Para hacer cualquier cosas necesito lo mismo 3 veces, pero cuando el proyecto es muy grande, con varios programadores tocando todo, esto es mucho más sólido y menos acoplado. Sobre todo se eliminan los impactos colaterales y permite refactorizar mejor.
Pero aún queda una cosa mas.
¿Por qué el DTO tiene string $status y no OrderStatusEnum $enum?
Seguimos pensando en objetos planos, con tipos básicos, pero realmente no es necesario. ¿o si?
En Pagos tenemos que comprobar si el pedido estaba preparado. ¿cómo lo hacemos????
if ($order->status === 'prepred')
Esto seguro que no, descartado, meter un enum en una cadena es lo mas peligroso.
(Intencionadamente he puesto una falta de ortografía en el if)
if ($order->status === OrderStatusEnum::prepared->value)
Esto parece más lógico, por alguien podría decir que Pagos tendría que conocer el enum, por eso lo descarta mucha gente y acaban con:
final class OrderDto
{
public function __construct(
public string $id,
public string $status,
public bool $isSent,
public bool $isDraft,
public bool $isPreparing
)
{
}
}
Aquí explicitamente sabemos si está preparado, pero esto requiere devolver todas las posibilidades de todos los enums en los DTOs, una locura.
¿Y si nuestro DTO truviera el enum?
final class Order
{
public function __construct(
public OrderId $id,
public OrderStatusEnum $status
)
{
}
}
if ($orderDto->status === OrderStatusEnum::prepared)
El problema aquí, es que los BoundedContext se acoplan a los Enums y ValueObjects de otros BoundedContext. Mierda.
Siguiente Opción:
final class Order
{
public function __construct(
public string $id,
public SharedOrderStatusEnum $status
)
{
}
}
if ($orderDto->status === SharedOrderStatusEnum::prepared)
En este caso, a nivel de proyecto, creamos unos estados compartidos que todos los dominios entienden. Por ejemplo, el BoundedContext de pedidos, podría tener 10 estados, pero a nivel general sólo nos interesan 5.
- Enviado = Enviado o Entregado.
- Devuelto = Devuelto por la agencia o Devuelto por el cliente.
Esta opción, además, permite cosas como:
$statusName = $orderDto->status->getLabel($lang);
if($orderDto->status->isCancellable())
Resumen
Piensa por qué usas los DTOS.
Piensa que nivel de acoplamiento puedes permitirte.
Piensa en que vas a crecer, y lo mismo acabas con 4 versiones de la API publicadas.
Piensa que es más fácil de probar.
Piensa cómo se evitan menos errores.
Leave a Reply