Evita acoplar casos de uso en los mappers


 

Un día, a una hora, en algún lugar

- Respecto a tu pregunta sobre si considero buena práctica utilizar casos de uso dentro de un mapper, mi respuesta es: generalmente NO, y diría que es una mala práctica en la mayoría de los escenarios.

Permíteme que te explique por qué:

  • Violación del Principio de Responsabilidad Única (SRP): Un mapper debe tener una única responsabilidad clara: transformar datos entre dos objetos. Introducir la lógica de un caso de uso (que encapsula una acción específica del sistema) dentro del mapper mezcla responsabilidades. El mapper se encargaría tanto de la transformación de datos como de la ejecución de una lógica de negocio particular.
  • Acoplamiento innecesario: Al incluir la lógica de un caso de uso en el mapper, estás acoplando el mapper a una funcionalidad específica del sistema. Si la lógica del caso de uso cambia, tu mapper también tendrá que modificarse, incluso si la estructura de los objetos que mapea sigue siendo la misma. Esto dificulta el mantenimiento y la reutilización del mapper.
  • Dificulta las pruebas unitarias: Probar un mapper que contiene lógica de casos de uso se vuelve más complejo. Tendrías que configurar el entorno necesario para ejecutar el caso de uso dentro de la prueba del mapper, en lugar de simplemente verificar la correcta transformación de los datos.
  • Menor claridad y legibilidad: Un mapper con lógica de casos de uso se vuelve más difícil de entender. Su propósito principal se difumina, y es menos obvio qué es lo que realmente está haciendo.

- Pero es que para la transformación de estos objetos necesito la lógica del caso de uso. Hay que obtener los datos necesarios para la transformación.

- Podría haber escenarios muy específicos donde una lógica de transformación, ligeramente más compleja, esté directamente relacionada con la estructura de los "objetos o sub-objetos" que se están mapeando. Sin embargo, incluso en estos casos, es preferible encapsular esa lógica en métodos privados dentro del mapper o en clases utilitarias específicas para la transformación, en lugar de acoplar un caso de uso. Es posible que esta dificultad que estás encontrando sea por un problema de diseño. Un mapper debería recibir los datos de un objeto y transformarlo en otro tipo de objeto.

- Ummm..., es posible que tengas razón. Pero ten en cuenta que estoy reutilizando el caso de uso, así me ahorro implementar otra lógica similar en el mapper. No queda mucho tiempo para la entrega, y así funciona.

- Bueno, es un atajo que estás tomando. En principio te puede parecer que ha sido más simple y rápido. Pero ya te digo que aunque incluir lógica de negocio en el mapper puede parecer una forma rápida de lograr un resultado, las consecuencias se pagan después. ¿Me puedes enseñar los test?

- Por ahora no hay pruebas de esa parte. Lo estoy probando manualmente, y funciona todo perfectamente.

- Vaya, sería interesante incluir los test, pero bueno. En resumen, y respondiendo a tu pregunta inicial, considera el mapper como un traductor puro entre dos estructuras de datos (objetos/entidades). Los casos de uso son las acciones que se realizan en el sistema y deben orquestarse en otra capa. Mantener estas responsabilidades separadas conduce a un código más limpio, mantenible y fácil de probar. El flujo típico sería algo así:

  1. La capa de presentación (controlador, interfaz de usuario) recibe una petición.
  2. La petición se delega a un caso de uso (en la capa de servicio/aplicación).
  3. El caso de uso contiene la lógica de negocio para esa acción específica. Es el responsable de orquestar las acciones del sistema, invocar la lógica de negocio y coordinar con diferentes componentes
  4. Si el caso de uso necesita interactuar con la capa de datos (por ejemplo, guardar o recuperar información), puede utilizar un repositorio (o DAO - Data Access Object).
  5. Antes de pasar datos a la capa de presentación o recibir datos de ella (o la de persistencia), se puede utilizar un mapper para transformar las entidades de la base de datos a modelos de dominio, proyecciones o DTOs (o viceversa). 

 

Esta conversación está inspirada en situaciones reales, realizando revisiones de código y manteniendo conversaciones con compañeros. En definitiva, utilizar casos de uso dentro de un mapper es una muy mala práctica, sin excepción. Hacerlo suena a la falta de separación de responsabilidades que a menudo se ve en etapas iniciales de desarrollo o en equipos con poca experiencia.

A veces, la inercia de un proyecto o la falta de comprensión de los principios de diseño pueden ser barreras difíciles de superar y es frustrante ver este "anti-patrón" repetido una y otra vez. Se ensucia la arquitectura, dificulta el mantenimiento y las pruebas. En general, va en detrimento de la calidad del software a largo plazo. Por todo ello: Evita acoplar casos de uso en los mappers.

Vamos a verlo con un ejemplo simple. Solo nos centramos en el acoplamiento de caso de uso en el mapper. Comenta si quieres otros "errores" que existen:


class User {
  final String id;
  final String name;
  final int age;

  User({required this.id, required this.name, required this.age});
}

class UserDTO {
  final String userId;
  final String fullName;
  final String? greeting; // El DTO puede incluir información adicional del caso de uso

  UserDTO({required this.userId, required this.fullName, this.greeting});
}

// Caso de uso: Generar un saludo personalizado basado en la edad
class GreetingUseCase {
  String generateGreeting(User user) {
    if (user.age >= 18) {
      return "Hola, ${user.name}! Bienvenido.";
    } else {
      return "Hola, ${user.name}. Eres joven.";
    }
  }
}

class UserMapper {
  final GreetingUseCase _greetingUseCase;

  UserMapper(this._greetingUseCase);

  UserDTO toDTOWithGreeting(User user) {
    final greeting = _greetingUseCase.generateGreeting(user);
    return UserDTO(userId: user.id, fullName: user.name, greeting: greeting);
  }

  UserDTO toDTO(User user) {
    return UserDTO(userId: user.id, fullName: user.name);
  }

  User fromDTO(UserDTO dto) {
    // En este ejemplo, la transformación inversa es más simple y no involucra el caso de uso.
    return User(id: dto.userId, name: dto.fullName, age: 0); // La edad no está en el DTO
  }
}

void main() {
  final user = User(id: "123", name: "Alice", age: 25);
  final greetingUseCase = GreetingUseCase();
  final userMapper = UserMapper(greetingUseCase);

  final userDTOWithGreeting = userMapper.toDTOWithGreeting(user);
  print('User DTO con saludo: ${userDTOWithGreeting.fullName} - ${userDTOWithGreeting.greeting}');

  final userDTOWithoutGreeting = userMapper.toDTO(user);
  print('User DTO sin saludo: ${userDTOWithoutGreeting.fullName}');

  final originalUser = userMapper.fromDTO(userDTOWithoutGreeting);
  print('Usuario original (aproximado): ${originalUser.name}, ${originalUser.age}');
}

¿Qué se aprecia en este código sobre el acoplamiento?

  • Dependencia Directa: El UserMapper tiene una dependencia directa de la clase GreetingUseCase. Se instancia GreetingUseCase en el constructor del UserMapper.

  • Lógica de Negocio en el Mapper: El método toDTOWithGreeting del UserMapper no solo se encarga de la transformación de User a UserDTO, sino que también invoca lógica de negocio específica del caso de uso de generación de saludos. ¿El mundo al revés?

  • Problemas de reutilización: Si tuvieras otro caso de uso que necesitara transformar User a UserDTO pero con una lógica diferente, tendrías que modificar el UserMapper o crear otro mapper específico. Esto limita la reutilización del UserMapper para transformaciones puras.

  • Peor Mantenimiento: Los cambios en el GreetingUseCase pueden impactar directamente al UserMapper, incluso si la lógica de transformación básica no ha cambiado.

  • Mayor Dificultad para Probar: Las pruebas unitarias del UserMapper ahora también involucran la lógica del GreetingUseCase.
  • Violación del Principio de Responsabilidad Única (SRP): El UserMapper tiene la responsabilidad de transformar datos y de invocar lógica de negocio.


¿Te convencen esas dificultades? 

Espero que si y evites acoplar casos de uso en los mappers.

 

 

Comentarios

José María Martínez Luna ha dicho que…
Enhorabuena Rafael por el artículo!!. Me parece sorprendente que se dé esta situación en un proyecto real ya que se puede prever como irá el proyecto a futuro. Con esa decisión técnica se incumplen todos los principios SOLID. El técnico que implemente esta solución es porque no tiene claros los conceptos. El caso de uso debe acoplarse a los repositorios, y éstos a los Mappers, con el objetivo de cumplir con los modelos de retorno correspondientes (dominio o DTOs) de los repositorios, los cuales son responsables del tratamiento con los datos pero no tiene sentido otro escenario.