ecosystem-wikicómo extender los templates sin romper sus convenciones
‹ todas las recipes

¿Mapper, o proyección inline?

template-api
último cambio 2026-09-25

Hay dos mappers por entidad, en direcciones opuestas, y nunca en el mismo fichero:

fila de Prisma  →  [persistence mapper]  →  Entidad  →  [presentation mapper]  →  DTO
Dónde Qué hace
Persistencia infrastructure/persistence/<x>/x.mapper.ts toDomain(row), y toPersistence(entity) si se escribe
Presentación presentation/<x>/x-response.mapper.ts toDto(entity)

En petid-api son 11 y 10. Que no sean 11 y 11 es el caso interesante, y está al final.

Por qué existen aunque sean de una línea

export class PetTagMapper {
  static toDomain(row: PetTagRow): PetTag {
    return new PetTag(row);
  }
}

Eso es todo el fichero. Parece ceremonia, y lo es hoy — pero es la costura: el día que añadas una columna que la entidad no tiene, o renombres un campo, hay un sitio evidente donde ponerlo y ninguna llamada a Prisma repartida por ahí que actualizar.

Son clases con métodos static. No se inyectan, no tienen estado.

La función que sí justifica la capa: lista blanca

UserResponseMapper no copia el objeto, enumera campo a campo:

static toDto(user: User): UserResponseDto {
  return {
    id: user.id,
    email: user.email,
    role: user.role,
    emailVerifiedAt: user.emailVerifiedAt,
    twoFactorEnabledAt: user.twoFactorEnabledAt,
    // ...
  };
}

User tiene password y twoFactorSecret. Al enumerar, esos campos no pueden salir por HTTP ni aunque alguien los añada a la entidad mañana. Con un spread ({ ...user }) saldrían solos.

Escribe siempre el mapper de presentación enumerando campos. Nunca { ...entity }, aunque hoy la entidad no tenga nada sensible.

Mantenlo puro: lo calculado entra por parámetro

// url la resuelve el llamante (buildResourceDownloadUrl) — así esto sigue
// siendo un mapper puro, y la URL nunca se persiste
static toDto(resource: Resource, url: string): ResourceResponseDto

El mapper no firma la URL ni consulta nada. Si un DTO necesita un dato que no está en la entidad, lo recibe. Un mapper que inyecta dependencias ha dejado de ser un mapper.

El único caso sin mapper de presentación

pet-tags no tiene pet-tag-response.mapper.ts. Su DTO es un campo:

// pet-tag.controller.ts
const tag = await this.getOrCreatePetTagUC.execute(petId, user);
return { publicCode: tag.publicCode };

Crear una clase con un método estático para copiar un campo es más ruido que valor.

Mapper de persistencia siempre. Mapper de presentación salvo que el DTO sea una proyección de uno o dos campos.

El límite práctico: si al escribir el mapper te sale un return de una o dos líneas y no hay nada que ocultar, hazlo inline en el controller. En cuanto haya tres campos, hay riesgo de que el cuarto sea sensible — y entonces toca mapper.

Variantes: métodos, no mappers nuevos

Cuando una entidad se sirve de varias formas, van como métodos del mismo mapper:

UserResponseMapper.toDto(user)                          // usuario a secas
UserResponseMapper.toProfileDto(user, name, lastName)   // + datos de perfil
UserResponseMapper.toProfileDtoFromProfile(user, profile)

No un UserWithProfileResponseMapper aparte.