Patrones de arquitectura con TypeScript: Repository, Service, DTOs y mappers tipados

Una API bien estructurada con TypeScript separa claramente las responsabilidades: el repositorio accede a los datos, el servicio contiene la lógica de negocio, los DTOs definen los contratos de la API y los mappers convierten entre representaciones internas y externas con tipos distintos para cada capa.

La entidad de dominio

// src/domain/usuario.ts

// Entidad de dominio: tipo interno, no expuesto directamente
export interface Usuario {
  id: number;
  nombre: string;
  email: string;
  hashContrasena: string;   // nunca sale de la capa de dominio
  creadoEn: Date;
  activo: boolean;
}

Repository pattern

El repositorio define una interfaz abstracta de acceso a datos. La implementación concreta puede ser PostgreSQL, MongoDB o un mock para tests:

// src/repositories/IUsuarioRepository.ts
export interface IUsuarioRepository {
  encontrarPorId(id: number): Promise<Usuario | null>;
  encontrarPorEmail(email: string): Promise<Usuario | null>;
  guardar(usuario: Omit<Usuario, 'id' | 'creadoEn'>): Promise<Usuario>;
  actualizar(id: number, cambios: Partial<Pick<Usuario, 'nombre' | 'activo'>>): Promise<Usuario>;
  eliminar(id: number): Promise<void>;
}

// src/repositories/PrismaUsuarioRepository.ts
import { PrismaClient } from '@prisma/client';
import type { IUsuarioRepository } from './IUsuarioRepository';

export class PrismaUsuarioRepository implements IUsuarioRepository {
  constructor(private prisma: PrismaClient) {}

  async encontrarPorId(id: number): Promise<Usuario | null> {
    return this.prisma.usuario.findUnique({ where: { id } });
  }

  async guardar(datos: Omit<Usuario, 'id' | 'creadoEn'>): Promise<Usuario> {
    return this.prisma.usuario.create({ data: datos });
  }

  // ... resto de métodos
}

DTOs: contratos de entrada y salida de la API

// src/dtos/usuario.dto.ts

// DTO de entrada: lo que llega del cliente
export interface CrearUsuarioDto {
  nombre: string;
  email: string;
  contrasena: string;        // texto plano, la capa de servicio lo hashea
}

export interface ActualizarUsuarioDto {
  nombre?: string;
  activo?: boolean;
}

// DTO de salida: lo que se devuelve al cliente (sin campos sensibles)
export interface UsuarioRespuestaDto {
  id: number;
  nombre: string;
  email: string;
  creadoEn: string;          // ISO string, no Date (para serialización JSON)
  activo: boolean;
}

Mappers tipados

// src/mappers/usuario.mapper.ts
import type { Usuario } from '../domain/usuario';
import type { UsuarioRespuestaDto } from '../dtos/usuario.dto';

export function usuarioADto(usuario: Usuario): UsuarioRespuestaDto {
  return {
    id: usuario.id,
    nombre: usuario.nombre,
    email: usuario.email,
    creadoEn: usuario.creadoEn.toISOString(),
    activo: usuario.activo,
  };
  // hashContrasena NUNCA aparece aquí — TypeScript lo garantiza porque
  // UsuarioRespuestaDto no tiene ese campo
}

export function usuariosADtos(usuarios: Usuario[]): UsuarioRespuestaDto[] {
  return usuarios.map(usuarioADto);
}

Service layer en NestJS

// src/services/usuario.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { hash } from 'bcrypt';
import type { IUsuarioRepository } from '../repositories/IUsuarioRepository';
import type { CrearUsuarioDto, UsuarioRespuestaDto } from '../dtos/usuario.dto';
import { usuarioADto } from '../mappers/usuario.mapper';

@Injectable()
export class UsuarioService {
  constructor(private readonly repo: IUsuarioRepository) {}

  async crearUsuario(dto: CrearUsuarioDto): Promise<UsuarioRespuestaDto> {
    const existente = await this.repo.encontrarPorEmail(dto.email);
    if (existente) throw new ConflictException('El email ya está registrado');

    const hashContrasena = await hash(dto.contrasena, 10);
    const usuario = await this.repo.guardar({
      nombre: dto.nombre,
      email: dto.email,
      hashContrasena,
      activo: true,
    });

    // El tipo de retorno es UsuarioRespuestaDto, sin hashContrasena:
    return usuarioADto(usuario);
  }

  async obtenerPorId(id: number): Promise<UsuarioRespuestaDto> {
    const usuario = await this.repo.encontrarPorId(id);
    if (!usuario) throw new NotFoundException(`Usuario ${id} no encontrado`);
    return usuarioADto(usuario);
  }
}

Imagen: Pexels / Myburgh Roux

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP