Skip to main content
gauchoscript

¿Qué es un DTO?

Las siglas están en inglés y se refieren a Data Transfer Object, que en español sería Objeto de Transferencia de Datos. Es un patrón de diseño que se utiliza a la hora de transferir datos entre componentes de software, ya sea entre distintos procesos en un sistema distribuido, entre capas de una misma aplicación o la arquitectura cliente-servidor de toda la vida.

Características #

Hasta acá todo muy abstracto, pero es muy probable que lo hayas usado, incluso sin saberlo. Veamos un pequeño ejemplo para ir entrando más en tema:

// UserDTO.php
class UserDTO {
  public $id;
  public $fullName;
  public $email;

  public function __construct($id, $fullName, $email) {
      $this->id = $id;
      $this->fullName = $fullName;
      $this->email = $email;
  }
}

Ya sé, estas pensando: «Pero eso es una entidad». Bueno, así sin más contexto podría ser...

Una entidad representa un objeto del modelo de dominio, posiblemente con su lógica de negocio y persistencia de datos en el sistema. En contraste un DTO es un objeto simple diseñado para transferir solo la información necesaria y optimizado para la serialización.

Comparémoslo con esta otra clase que representaría a una entidad para ilustrar mejor las diferencias.

// User.php
class User {
  public $id;
  public $firstName;
  public $lastName;
  public $email;
  public $password; // Información sensible
  public $createdAt; // Fecha de creación
  public $lastLogin; // Fecha del último inicio de sesión

  public function __construct($id, $firstName, $lastName, $email, $password, $createdAt, $lastLogin) {
    $this->id = $id;
    $this->firstName = $firstName;
    $this->lastName = $lastName;
    $this->email = $email;
    $this->password = $password; // No querés mandar esto al cliente
    $this->createdAt = $createdAt;
    $this->lastLogin = $lastLogin;
  }
}

De a poco va cayendo la ficha, ¿o no?.

Como ven en el DTO estamos evitando enviar varias propiedades sensibles y/o no necesarias para el destinatario (que de ahora en más llamaremos cliente ya que vamos a enfocarnos en el uso entre servidor y cliente).

Principales problemas que resuelve #

Muchas veces, por comodidad y rapidez terminamos enviando las entidades crudas con las que trabajamos en el servidor directamente al cliente, lo que trae una serie de desventajas:

1. Acoplamos completamente el cliente al modelo de dominio #

Esto termina generando que cualquier cambio hecho en la entidad afecte directamente al cliente generando mayor complejidad.

Pensemos en el ejemplo anterior, y supongamos que necesitamos mostrar el nombre completo del usuario en el cliente. Si estamos usando la entidad User directamente deberíamos encargarnos de concatenar firstName con lastName en el cliente, haciendo algo asi:

// userProfile.js
const user = await fetch('/api/user/1')
    .then(response => response.json())
    .then(user => {
        user.fullName = `${user.firstName} ${user.lastName}`;
        return user;
    });

console.log(user.fullName);

Seguro que esto lo viste (o hiciste) mil veces. Ahora imaginemos que por algún requisito místico de producto tenemos que cambiar el modelo reemplazando las columnas firstName y lastName en la base de datos por una nueva columna llamada fullName. Con este esquema, tendríamos que tocar además de la entidad User el archivo userProfile.js en el cliente. Si no lo hicieramos, nos daría un error porque las propiedades que esta esperando para armar el nombre completo no existen más.

A esto es a lo que me refiero con que al usar entidades directamente estamos acoplando el cliente a las decisiones del modelo de dominio.

Ahora veamos cómo sería el mismo escenario utilizando un DTO.

// User.php
class User {
    public $id;
    public $firstName;
    public $lastName;
    public $email;
    public $password;
    public $createdAt;
    public $lastLogin;

    public function __construct($id, $firstName, $lastName, $email, $password, $createdAt, $lastLogin) {
        $this->id = $id;
        $this->firstName = $firstName;
        $this->lastName = $lastName;
        $this->email = $email;
        $this->password = $password;
        $this->createdAt = $createdAt;
        $this->lastLogin = $lastLogin;
    }
}

// UserDTO.php
class UserDTO {
    public $id;
    public $fullName;
    public $email;

    public function __construct($id, $fullName, $email) {
        $this->id = $id;
        $this->fullName = $fullName;
        $this->email = $email;
    }
    
    public static function fromEntity(User $user) {
        $fullName = $user->firstName . ' ' . $user->lastName;
        return new self($user->id, $fullName, $user->email);
    }
}

// UserService.php
class UserService {
    public function getUserDTO($id) {
        // Simulación de obtener el usuario de la base de datos
        $user = new User(1, "Juan", "Pérez", "juan@ejemplo.com", "contraseña_secreta", new DateTime());
        return UserDTO::fromEntity($user);
    }
}
// userProfile.js
const user = await fetch('/api/user/1')
    .then(response => response.json());

console.log(user.fullName);

Como ves, en este escenario cualquier cambio al modelo de dominio permanece ajeno a nuestro cliente, ya que el UserDTO se encarga de eso. También ahorramos complejidad en el cliente pasando la lógica de concatenación al UserDTO que es quien se encarga de mapear la entidad en DTO en este simple esquema.

2. Riesgos de seguridad #

Como vimos en el primer ejemplo hay propiedades dentro de la entidad pensadas para ser manejadas por un servidor seguro, como es el caso de $password , tokens y demás información sensible que no queremos que se filtre al cliente.

3. No todas las propiedades que mapeamos en entidades son serializables #

Fijate en la propiedad createdAt de la entidad User que es un objeto DateTime. El objeto DateTime es una clase de PHP que almacena información de fecha y hora, pero no es fácilmente serializable a JSON directamente.

// User.php
class User {
    public $id;
    public $name;
    public $email;
    public $createdAt; // Este es un objeto DateTime

    public function __construct($id, $name, $email, $password, $createdAt) {
        $this->id = $id;
        $this->name = $name;
        $this->email = $email;
        $this->createdAt = $createdAt; // DateTime no se serializa directamente a JSON
    }
}

// UserService.php
class UserService {
    public function getUser($userId) {
        return new User($userId, "Juan Pérez", "juan@ejemplo.com", new DateTime("2024-01-01"));
    }
}

// api.php
$userId = //Obtener el userId desde la request url
$userService = new UserService();
$user = $userService->getUser($userId);

header('Content-Type: application/json');
echo json_encode($user);
// Salida JSON incorrecta (o generará un error dependiendo de la configuración)
// {"id":1,"name":"Juan Pérez","email":"juan@ejemplo.com","createdAt":{}}

El resultado no incluye una representación útil de la fecha, o podría incluso fallar dependiendo del entorno. Y vos dirás: «Pero es cuestión de quitar el new DateTime() y dejar solo el valor», y tenés razón, pero solo en este ejemplo minimalista.

Pensemos en un sistema más grande donde la entidad de User se recupera a través de un ORM y la propiedad createdAt es un DateTime porque se hacen muchas operaciones con ella y rinde más que así sea.

Pasemos a solucionar este escenario agregando el DTO a la mezcla.

// User.php
class User {
    public $id;
    public $name;
    public $email;
    public $createdAt;

    public function __construct($id, $name, $email, $password, $createdAt) {
        $this->id = $id;
        $this->name = $name;
        $this->email = $email;
        $this->createdAt = $createdAt;
    }
}

// UserDTO.php
class UserDTO {
    public $id;
    public $name;
    public $email;
    public $createdAt;

    public function __construct($id, $name, $email, $password, $createdAt) {
        $this->id = $id;
        $this->name = $name;
        $this->email = $email;
        $this->createdAt = $createdAt;
    }
    
    public static function fromEntity(User $user) {
        // Convertimos el objeto DateTime a string antes de asignarlo
        $createdAt = $user->createdAt->format(DateTime::ATOM);
        return new self($user->id, $user->name, $user->email, $createdAt);
    }
}

// UserService.php
class UserService {
    public function getUser($userId) {
        $user = new User($userId, "Juan Pérez", "juan@ejemplo.com", new DateTime("2024-01-01"));
        return UserDTO::fromEntity($user);
    }
}

// api.php
$userId = //Obtenemos el userId desde la request url
$userService = new UserService();
$user = $userService->getUser($userId);

header('Content-Type: application/json');
echo json_encode($user);
// Salida JSON correcta:
// {"id":1,"name":"Juan Pérez","email":"juan@ejemplo.co","createdAt":"2024-01-01T00:00:00+00:00"}

Y estos son sólo algunos escenarios muy simples donde se puede ver el valor de aplicar este patrón, por lo menos para saciar las dudas iniciales si es la primera vez que nos cruzamos con estas siglas.

Podríamos ahondar mucho más en el tema, estoy seguro que como sos bien pillo, habrás notado que asi como existe método fromEntity dentro del UserDTO encargado de transformar la entidad en el DTO debería existir la operación inversa para cuando hay que persistir la información.

A estas funciones se las conoce como mapeadores y son los responsables de encapsular la lógica de transformación entre entidad y DTOs. Y como bien señale al principio del articulo, los DTOs deben permanecer simples por lo que estos mapeadores pertenecen a otro lugar del código, para mantener la separación de responsabilidades.

También podríamos hablar más de una de las mayores ventajas en aplicaciones de gran escala que apenas tocamos. Se puede optimizar mucho el uso de la red en aplicaciones donde la velocidad es crítica reduciendo la cantidad de idas y vueltas al servidor ya que un mísmo DTO puede estar conformado con información de varias entidades distintas, devolviendo en una sola llamada todo lo que el cliente necesite para imprimir en pantalla.

Imaginemos un UserProfileDTO más completo que, además de la información básica del usuario, tenga también información adicional de otros servicios que deben verse en el perfil del usuario, como su foto de perfil, datos de contacto, datos de facturacion, preferencias y demás.

Todo esto que muchas veces suele obtenerse con multiples puntos de acceso de una API REST y después ensamblarse en el cliente aumentando su complejidad.

Como te digo, todavía queda mucho para hablar, pero como esta es mi primera publicación, voy a dejar hasta acá y vemos si hay interés en el tema para seguir desarrollandolo en un futuro.