Patrón de repositorio para backends escalables en Node.js
El patrón de repositorios en Node.js: ocultar Prisma detrás de repositorios inyectados y una fábrica, para que los servicios sigan siendo probables y las transacciones permanezcan atómicas en TypeScript.
Fuentes: Decoupling Data: Building Scalable Node.js Backends with the Repository Pattern
Los backends antiguos solían consultar la base de datos directamente desde los métodos de servicio. Eso está bien para alcances pequeños. Una vez que los sistemas necesitan fiabilidad en entornos de producción, el acoplamiento estrecho entre las reglas de negocio y las llamadas a Prisma se convierte en un cuello de botella para el mantenimiento. El patrón Repository es una forma de mantener la base de código escalable y probable.
El problema: el “servicio divino”
Los servicios pueden empezar a verse así:
await prisma.user.findUnique({ where: { email } });
await prisma.user.create({ data });
// ... 100 more lines of database logic mixed with business rules ...
Al principio parece sencillo. Con el tiempo aparecen los costos:
- Fugas de información: detalles en formato SQL se infiltran en las reglas de negocio.
- Duplicación: las mismas llamadas a Prisma se copian entre diferentes servicios.
- Probabilidad de pruebas: las pruebas lógicas requieren una base de datos en funcionamiento.
- Fragilidad: cambiar el nombre de una tabla o campo afecta a todos los servicios.
Eso es acoplamiento estricto: la lógica de registro o autenticación está vinculada directamente a Prisma. Hacer pruebas unitarias sin una base de datos real se vuelve casi imposible. Una capa dedicada al acceso a datos es la pieza que falta.
La solución: el patrón de repositorio
Un repositorio actúa como intermediario entre la lógica de negocio y la fuente de datos. Los servicios solicitan datos a los repositorios sin importar si la respuesta proviene de MySQL, PostgreSQL u otro backend. Los servicios dejan de comunicarse directamente con Prisma; en su lugar, se comunican con los repositorios.
Idea central: los servicios deben preocuparse por qué reciben, no por cómo se obtuvo.
Definir un contrato genérico
Una interfaz compartida mantiene consistentes a los repositorios:
export interface IRepository<T> {
findById(id: string): Promise<T | null>;
create(data: any): Promise<T>;
update(id: string, data: any): Promise<T>;
delete(id: string): Promise<T>;
}
UserRepository (solo lógica de base de datos)
La función de un repositorio es la persistencia. No debe crear instancias de Prisma; debe recibir un cliente a través del constructor.
export class UserRepository {
constructor(private prisma: PrismaClient) {}
async findByEmail(email: string) {
return this.prisma.user.findUnique({
where: { email },
});
}
async markEmailAsVerified(id: string) {
return this.prisma.user.update({
where: { id },
data: { isEmailVerified: true },
});
}
}
El repositorio no es responsable de crear Prisma; simplemente acepta una instancia.
Por qué es obligatorio pasar PrismaClient (inyección de dependencias)
Evite crear clientes dentro de los repositorios:
new PrismaClient(); // ❌ inside repository
En su lugar, inyéctelos:
constructor(private prisma: PrismaClient) {}
Por qué es importante
- Conexiones compartidas: evita la creación accidental de grupos de clientes.
- Transacciones atómicas: varios repositorios pueden compartir una misma sesión transaccional.
- Posibilidad de pruebas: el cliente (o repositorio) puede simularse en las pruebas unitarias.
Ese patrón se denomina inyección de dependencias, un hábito estándar en el diseño de backends.
Fábrica de singleton: una única fuente de verdad para los repositorios
Una fábrica de repositorios con una instancia singleton y creación perezosa mantiene el código organizado:
export class RepositoryFactory {
private static instance: RepositoryFactory;
private _userRepository?: UserRepository;
private constructor(private prisma: PrismaClient) {}
static getInstance(prisma: PrismaClient) {
if (!this.instance) {
this.instance = new RepositoryFactory(prisma);
}
return this.instance;
}
// Lazy Initialization: Only creates the repo if it's called
public get userRepository(): UserRepository {
if (!this._userRepository) {
this._userRepository = new UserRepository(this.prisma);
}
return this._userRepository;
}
}t
Luego, los servicios llaman a métodos limpios sin usar SQL:
this.userRepository.findByEmail(email);
¿Por qué un singleton? Una sola fábrica para el proceso implica un único PrismaClient compartido, sin conexiones duplicadas y con instancias de repositorios consistentes.
La gran ventaja: transacciones atómicas
Los flujos de autenticación suelen requerir integridad en múltiples pasos. Cuando un usuario verifica su correo electrónico, el sistema puede actualizar al usuario y eliminar un OTP. Ambas operaciones deben tener éxito o fallar ambas.
Dado que los repositorios comparten el cliente inyectado, una transacción puede abarcarlas a todas:
await prisma.$transaction(async (tx) => {
// We pass the transaction 'tx' into our factory
const repos = RepositoryFactory.getInstance(tx);
await repos.userRepository.markEmailAsVerified(userId);
await repos.otpRepository.delete(otpId);
});
Si algo falla en el proceso, Prisma revierte toda la operación; no queda un estado de verificación parcialmente aplicado.
Por qué es importante:
- La verificación y la eliminación del OTP se realizan juntas con éxito
- Cualquier fallo revierte todo el proceso
Desacoplamiento
Antes: los servicios dependían directamente de Prisma.
Después: los servicios dependen de repositorios; los repositorios dependen de PrismaClient.
AuthService → UserRepository → Prisma
Eso significa que se puede reemplazar Prisma posteriormente sin tener que volver a escribir los servicios, la lógica de negocio sigue siendo legible y el código de la base de datos puede evolucionar por sí mismo.
El superpoder secreto: la posibilidad de pruebas (simulación)
Cuando las consultas están ocultas dentro de los servicios, las pruebas necesitan una base de datos en tiempo real y se vuelven lentas o inestables. Con los repositorios, las pruebas unitarias pueden utilizar simulaciones.
En lugar de MySQL, se pasa un UserRepository falso a AuthService:
// Example: Testing the Register logic without a database
const mockUserRepository = {
findByEmail: jest.fn().mockResolvedValue(null), // Pretend user doesn't exist
create: jest.fn().mockResolvedValue({ id: '123', email: 'test@test.com' })
};
// Now I can test the Service logic in isolation
const authService = new AuthService(mockUserRepository as any);
const result = await authService.register(userData);
expect(result.user.email).toBe('test@test.com');
expect(mockUserRepository.create).toHaveBeenCalled();
Por qué esto es importante desde el punto de vista operativo
- Rapidez: se pueden ejecutar miles de pruebas sin la carga adicional de la red o la base de datos.
- Fiabilidad: los casos límite como los tiempos de espera son fáciles de simular haciendo que el mock lance una excepción.
- Confianza: los refactores reciben retroalimentación inmediata cuando la lógica se desvía.
Qué enseña esta implementación
- Separación de responsabilidades: los servicios gestionan las reglas de negocio; los repositorios se encargan de las consultas.
- Escalabilidad: los nuevos dominios (reservas, alquileres, etc.) siguen la misma estructura.
- Estructura de producción: el diseño coincide con los patrones comunes en sistemas más grandes.
Pensamientos finales
Mover Prisma detrás de los repositorios es más que un simple reordenamiento de archivos. Los servicios se vuelven más claros, las pruebas son menos costosas y el backend está mejor preparado para crecer. Si las llamadas a Prisma están convirtiendo a los servicios en objetos divinos, el patrón de repositorios es un paso práctico siguiente.
Lecturas relacionadas
- TypeScript sin Node ni V8: Pruebas de rendimiento de binarios nativos de scriptc — Tiempo de inicio en frío, RSS, rendimiento y compromisos de CPU cuando el mismo servidor HTTP de TypeScript se ejecuta como un binario nativo de scriptc en lugar de con Node.js y V8.
- Mover un backend de NestJS a un monorepo con pnpm y Turborepo — Compartir contratos en TypeScript entre APIs mediante espacios de trabajo de pnpm, gráficos de tareas de Turborepo, Dockerfiles optimizados para el caché y catálogos de pnpm, sin convertir los archivos comunes en un desván de trastos.