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
