🚨 ¡Nueva review! ¡Mi teclado ideal! ⌨️ Perfecto para programar, el Logitech MX Keys S . ¡Échale un ojo! 👀

CRUD tipado completo con Repository Pattern en NestJS

Serie NestJS #8 — Repository<Entity>, @InjectRepository, paginación, filtros, soft delete y transacciones

Escrito por domin el 1 de abril de 2026

Vamos con el octavo post de la serie NestJS. En el post anterior montamos PostgreSQL con Docker Compose, configuramos TypeORM con variables de entorno y creamos nuestra primera entidad User con todos sus decoradores. Pero la entidad por si sola es una vaga, no hace nada, así que hoy vamos a darle trabajo.

Vamos a hacer un CRUD completito: crear, leer, actualizar y eliminar usuarios. Pero no un CRUD cutrecito e ya, sino un CRUD con paginación tipada, filtros dinámicos, soft delete, respuestas consistentes y transacciones. Vamos que va a ser un CRUD pata negra.

Vamos a ver todo lo que hemos aprendido hasta ahora, cómo los DTOs validados del post 6, la entidad del post 7, la inyección de dependencias del post 3 y los módulos del post 4.

EA, amo al lío.

Diagrama del flujo CRUD completo en NestJS con Repository Pattern conectado a PostgreSQL.

1. Repository Pattern: qué es y por qué usarlo

El es una capa de abstracción entre tu lógica de negocio y la base de datos. TypeORM implementa este patrón con la clase Repository<Entity>, que te da métodos tipados para interactuar con la tabla.

¿Y por qué usar un Repository y no hacer queries directas en el service?

🔗 Desacoplamiento

El service no sabe si los datos vienen de PostgreSQL, MySQL o un mock. Cambia el origen de datos sin tocar la lógica de negocio.

🧪 Testabilidad

En tests, sustituyes el repository real por un mock. No necesitas base de datos para testear la lógica del service.

📝 Tipado completo

Repository conoce las propiedades de User. Los métodos find, findOne, save devuelven User tipado. Autocompletado en el IDE.

📦 Métodos built-in

find, findOne, save, remove, count, exists, createQueryBuilder... No reimplementas operaciones básicas.


2. @InjectRepository: inyectar el repositorio

En el post 7 registramos la entidad con TypeOrmModule.forFeature([User]). Esto hace que NestJS cree automáticamente un Repository<User> que podemos inyectar con @InjectRepository():

// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';

@Injectable()
export class UsersService {
    constructor(
        @InjectRepository(User)
        private readonly usersRepository: Repository<User>,
    ) {}
}

Repository<User> es genérico y todos sus métodos saben que trabajan con la entidad User. Si intentas hacer this.usersRepository.find({ where: { campoQueNoExiste: 'x' } }) TypeScript te va a gritar.


3. Create: crear un registro

// src/users/users.service.ts
import { Injectable, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { type CreateUserDto } from './dto/create-user.dto';
import * as bcrypt from 'bcrypt';

@Injectable()
export class UsersService {
    constructor(
        @InjectRepository(User)
        private readonly usersRepository: Repository<User>,
    ) {}

    async create(createUserDto: CreateUserDto): Promise<User> {
        const existingUser = await this.usersRepository.findOne({
            where: { email: createUserDto.email },
        });

        if (existingUser) {
            throw new ConflictException(`El email ${createUserDto.email} ya está registrado`);
        }

        const hashedPassword = await bcrypt.hash(createUserDto.password, 10);

        const user = this.usersRepository.create({
            ...createUserDto,
            password: hashedPassword,
        });

        return this.usersRepository.save(user);
    }
}

Dos pasos importantes aquí:

create() vs save(): No son lo mismo

// ❌ Esto funciona pero salta la instanciación de la entidad
await this.usersRepository.save(createUserDto);

// ✅ Esto crea la instancia primero y luego la guarda
const user = this.usersRepository.create(createUserDto);
await this.usersRepository.save(user);

Siempre create() + save(). El create() aplica los valores por defecto de la entidad (active: true, role: UserRole.USER), convierte el objeto plano a instancia, y ejecuta los entity listeners como @BeforeInsert. Con solo save() te saltas todo eso.

Hashear el password

Usamos bcrypt para hashear el password antes de guardarlo. Nunca, nunca, nunca almacenes contraseñas en texto plano, que si luego un tercero logra el acceso a la base de datos o consigue filtrar algo todo son risas.

Instalación de bcrypt 0 / 1
$
Pulsa para ejecutar el siguiente comando

4. Read: leer registros

4.1. Buscar uno por ID

async findOne(id: string): Promise<User> {
    const user = await this.usersRepository.findOne({
        where: { id },
    });

    if (!user) {
        throw new NotFoundException(`Usuario con id ${id} no encontrado`);
    }

    return user;
}

findOne() devuelve User | null. Siempre comprueba el null y lanza NotFoundException. Nunca devuelvas null desde un endpoint, mejor, devuelve un 404 con un mensaje.

4.2. Buscar todo

async findAll(): Promise<User[]> {
    return this.usersRepository.find({
        order: { createdAt: 'DESC' },
    });
}

find() sin where devuelve todos los registros. El order define la ordenación, como se puede presuponer. Pero en una API real nunca devuelves todos los registros, es una locura y se necesita paginación, que lo veremos un poquito más adelante, en el punto 6.

4.3. Buscar por condiciones

// Buscar por email
const user = await this.usersRepository.findOne({
    where: { email: 'domin@domin.es' },
});

// Buscar varios por condición
const activeAdmins = await this.usersRepository.find({
    where: { active: true, role: UserRole.ADMIN },
});

// OR condition: activos O administradores
const usersOrAdmins = await this.usersRepository.find({
    where: [
        { active: true },
        { role: UserRole.ADMIN },
    ],
});

Las condiciones dentro de un mismo objeto son AND y un array de objetos es OR. TypeScript valida que las propiedades que usas en where existan en la entidad User.

4.4. FindOperators: queries avanzadas tipadas

TypeORM trae operadores de comparación para queries más complejas:

import { Like, ILike, In, Between, IsNull, Not, LessThan, MoreThanOrEqual } from 'typeorm';

// ILIKE (case-insensitive) — buscar por nombre
const users = await this.usersRepository.find({
    where: { name: ILike(`%domin%`) },
});

// IN — buscar varios IDs a la vez
const specificUsers = await this.usersRepository.find({
    where: { id: In(['uuid-1', 'uuid-2', 'uuid-3']) },
});

// BETWEEN — rango de fechas
const recentUsers = await this.usersRepository.find({
    where: { createdAt: Between(startDate, endDate) },
});

// NOT — negación
const nonAdmins = await this.usersRepository.find({
    where: { role: Not(UserRole.ADMIN) },
});

// Combinados
const filteredUsers = await this.usersRepository.find({
    where: {
        active: true,
        name: ILike(`%${search}%`),
        createdAt: MoreThanOrEqual(sinceDate),
    },
});

Todos estos operadores son tipados. ILike solo acepta columnas string, Between requiere dos valores del mismo tipo que la columna, In requiere un array del tipo correcto.


5. Update para actualizar un registro

async update(id: string, updateUserDto: UpdateUserDto): Promise<User> {
    const user = await this.findOne(id); // Reutilizamos findOne que ya lanza 404

    if (updateUserDto.password) {
        updateUserDto = {
            ...updateUserDto,
            password: await bcrypt.hash(updateUserDto.password, 10),
        };
    }

    const updatedUser = this.usersRepository.merge(user, updateUserDto);
    return this.usersRepository.save(updatedUser);
}

merge() + save()es el patrón correcto para updates

Por qué NO usar update() directamente

// ⚠️ Funciona pero tiene limitaciones
await this.usersRepository.update(id, updateUserDto);

update() ejecuta el SQL directamente, sin pasar por las instancias de la entidad y eso significa que:

Úsalo cuando...
  • merge() + save() para updates que necesitan entity listeners, hooks o devolver la entidad completa
  • update() para updates masivos o cuando el rendimiento prima sobre las features (actualizar 10.000 filas)
Evítalo cuando...
  • update() cuando necesitas la entidad actualizada de vuelta (no la devuelve)
  • Hacer findOne + modificar propiedades a mano en vez de merge(). merge() es más limpio y declarativo

6. Paginación tipada: no devuelvas 10.000 registros

Toda API real necesita paginación, vamos a ver un ejemplo:

6.1. Interfaz de respuesta paginada

// src/common/interfaces/paginated-result.interface.ts
export interface PaginatedResult<T> {
    data: T[];
    meta: {
        total: number;
        page: number;
        limit: number;
        totalPages: number;
        hasNextPage: boolean;
        hasPreviousPage: boolean;
    };
}

Esta interfaz es genérica (<T>). Funciona para User, Product, Order o cualquier entidad, así podemos tener un solo tipo para toda la API.

6.2. DTO de paginación

// src/common/dto/pagination.dto.ts
import { IsOptional, IsNumber, Min, Max } from 'class-validator';

export class PaginationDto {
    @IsOptional()
    @IsNumber()
    @Min(1)
    readonly page: number = 1;

    @IsOptional()
    @IsNumber()
    @Min(1)
    @Max(100)
    readonly limit: number = 10;
}

6.3. Implementación en el service

async findAll(paginationDto: PaginationDto): Promise<PaginatedResult<User>> {
    const { page, limit } = paginationDto;
    const skip = (page - 1) * limit;

    const [data, total] = await this.usersRepository.findAndCount({
        order: { createdAt: 'DESC' },
        skip,
        take: limit,
    });

    const totalPages = Math.ceil(total / limit);

    return {
        data,
        meta: {
            total,
            page,
            limit,
            totalPages,
            hasNextPage: page < totalPages,
            hasPreviousPage: page > 1,
        },
    };
}

findAndCount() es la clave porque ejecuta dos queries en paralelo: un SELECT con LIMIT y OFFSET para los datos, y un COUNT(*) para el total. Devuelve una tupla [entities[], count].

Paginación en acción 0 / 1
$
Pulsa para ejecutar el siguiente comando

El frontend tiene todo lo que necesita para construir la paginación: total de registros, página actual, si hay siguiente/anterior, total de páginas, sin tener que calcular nada.


7. Filtros dinámicos: queries flexibles

En una API real, el endpoint de listado acepta filtros: buscar por nombre, filtrar por estado, por rol… Vamos a montar un sistema de filtros que escale:

7.1. DTO de filtros

// src/users/dto/filter-users.dto.ts
import { IsOptional, IsString, IsBoolean, IsEnum } from 'class-validator';
import { IntersectionType } from '@nestjs/mapped-types';
import { PaginationDto } from '../../common/dto/pagination.dto';
import { UserRole } from '../enums/user-role.enum';

class FilterFieldsDto {
    @IsOptional()
    @IsString()
    readonly search?: string;

    @IsOptional()
    @IsBoolean()
    readonly active?: boolean;

    @IsOptional()
    @IsEnum(UserRole)
    readonly role?: UserRole;
}

export class FilterUsersDto extends IntersectionType(PaginationDto, FilterFieldsDto) {}

Usamos IntersectionType del post 6 para combinar los filtros de paginación con los filtros específicos de usuarios, así tenemos un solo DTO para todo.

7.2. Construir el where dinámicamente

import { ILike, type FindOptionsWhere } from 'typeorm';

async findAll(filterDto: FilterUsersDto): Promise<PaginatedResult<User>> {
    const { page, limit, search, active, role } = filterDto;
    const skip = (page - 1) * limit;

    const where: FindOptionsWhere<User> = {};

    if (search) {
        where.name = ILike(`%${search}%`);
    }

    if (active !== undefined) {
        where.active = active;
    }

    if (role) {
        where.role = role;
    }

    const [data, total] = await this.usersRepository.findAndCount({
        where,
        order: { createdAt: 'DESC' },
        skip,
        take: limit,
    });

    const totalPages = Math.ceil(total / limit);

    return {
        data,
        meta: {
            total,
            page,
            limit,
            totalPages,
            hasNextPage: page < totalPages,
            hasPreviousPage: page > 1,
        },
    };
}

FindOptionsWhere<User> es el tipo que TypeORM usa para el where, y es genérico porque solo te deja poner propiedades que existan en User, con los tipos correctos. Si intentas where.email = 42, TypeScript te grita.

Construimos el where condicionalmente y solo añadimos las propiedades que vienen en el DTO. Si search es undefined, no se filtra por nombre. Si active no viene, no se filtra por estado. Así el mismo endpoint sirve para GET /users, GET /users?active=true y GET /users?search=domin&role=admin.

7.3. Búsqueda en múltiples campos

¿Qué pasa si quieres buscar por nombre o email? Con el where de Find Options necesitas un array:

if (search) {
    const baseWhere: FindOptionsWhere<User> = {};

    if (active !== undefined) baseWhere.active = active;
    if (role) baseWhere.role = role;

    const [data, total] = await this.usersRepository.findAndCount({
        where: [
            { ...baseWhere, name: ILike(`%${search}%`) },
            { ...baseWhere, email: ILike(`%${search}%`) },
        ],
        order: { createdAt: 'DESC' },
        skip,
        take: limit,
    });

    // ...
}

Cada objeto del array es una condición OR. Así search=domin encuentra usuarios que tengan “domin” en el nombre o en el email, manteniendo el resto de filtros (active, role) en ambas condiciones.


8. Soft delete: borrar sin borrar

En el post 7 añadimos @DeleteDateColumn() a la entidad User, eso habilita el .

8.1. Borrado lógico

async remove(id: string): Promise<void> {
    const user = await this.findOne(id);
    await this.usersRepository.softRemove(user);
}

softRemove() no ejecuta DELETE. Ejecuta un UPDATE que pone la fecha actual en deletedAt. El registro sigue en la tabla pero se vuelve “invisible” para las queries normales.

8.2. ¿Cómo funciona la invisibilidad?

Cuando tu entidad tiene @DeleteDateColumn(), TypeORM añade automáticamente WHERE "deletedAt" IS NULL a todos los find(), findOne(), findAndCount(), etc. Los registros borrados de manera soft no van a aparecer en los resultados, sin hacer nada más.

8.3. Recuperar un registro borrado

async restore(id: string): Promise<User> {
    const result = await this.usersRepository.restore(id);

    if (result.affected === 0) {
        throw new NotFoundException(`Usuario con id ${id} no encontrado o no está eliminado`);
    }

    return this.findOne(id);
}

restore() pone deletedAt a NULL y el registro vuelve a ser visible.

8.4. Consultar registros borrados

Si necesitas ver los registros borrados (panel de admin, auditoría), usa withDeleted:

// Todos los registros, incluyendo los borrados
const allUsers = await this.usersRepository.find({
    withDeleted: true,
});

// Solo los borrados
const deletedUsers = await this.usersRepository.find({
    withDeleted: true,
    where: { deletedAt: Not(IsNull()) },
});
Úsalo cuando...
  • Soft delete para datos que necesitas auditar, recuperar o que tienen implicaciones legales (GDPR, facturas)
  • Hard delete (remove()) para datos temporales, sesiones, tokens expirados, colas procesadas
Evítalo cuando...
  • Soft delete en todas las tablas "por si acaso". Las tablas con soft delete crecen para siempre
  • Olvidar que los registros soft-deleted siguen contando para índices únicos. Si borras un user con email X e intentas crear otro con el mismo email, falla

8.5. Soft delete e índices únicos: la trampa

Si tienes un índice único en email y haces soft delete de un usuario con domin@domin.es, ese email sigue ocupando el índice. Si alguien intenta registrarse con el mismo email, falla con un error de unique constraint.

La solución es un partial unique index que solo considere registros no borrados:

@Entity('users')
@Index(['email'], { unique: true, where: '"deletedAt" IS NULL' })
export class User {
    // ...
}

Ahora el índice único solo aplica a registros activos. Un registro borrado libera el email.


9. Transacciones con QueryRunner

Cuando necesitas ejecutar varias operaciones que deben ser todo o nada (crear un usuario y su perfil, transferir dinero entre cuentas…), usas una .

9.1. QueryRunner: control total

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { User } from './entities/user.entity';

@Injectable()
export class UsersService {
    constructor(
        @InjectRepository(User)
        private readonly usersRepository: Repository<User>,
        private readonly dataSource: DataSource,
    ) {}

    async createWithProfile(
        createUserDto: CreateUserDto,
        createProfileDto: CreateProfileDto,
    ): Promise<User> {
        const queryRunner = this.dataSource.createQueryRunner();

        await queryRunner.connect();
        await queryRunner.startTransaction();

        try {
            const user = queryRunner.manager.create(User, {
                ...createUserDto,
                password: await bcrypt.hash(createUserDto.password, 10),
            });
            const savedUser = await queryRunner.manager.save(user);

            const profile = queryRunner.manager.create(Profile, {
                ...createProfileDto,
                userId: savedUser.id,
            });
            await queryRunner.manager.save(profile);

            await queryRunner.commitTransaction();
            return savedUser;
        } catch (error) {
            await queryRunner.rollbackTransaction();
            throw error;
        } finally {
            await queryRunner.release();
        }
    }
}

Paso a paso:

  1. createQueryRunner(): Crea un runner que controla una conexión dedicada.
  2. connect(): Obtiene una conexión del pool.
  3. startTransaction(): Inicia la transacción (BEGIN).
  4. queryRunner.manager: Usa este EntityManager en vez del repository normal, todas las operaciones pasan por la misma transacción.
  5. commitTransaction(): Si todo va bien, confirma los cambios (COMMIT).
  6. rollbackTransaction(): Si algo falla, revierte todo (ROLLBACK), ni el usuario ni el perfil se crean.
  7. release(): Devuelve la conexión al pool, siempre en finally para evitar leaks.

9.2. El patrón try/catch/finally es obligatorio

Si no haces release(), la conexión se queda abierta. Después de unas cuantas peticiones fallidas sin hacer release, agotarás el pool de conexiones y tu app dejará de funcionar. El finally garantiza que siempre se libera, haya error o no.

9.3. Cuándo usar transacciones

Úsalo cuando...
  • Cuando creas/actualizas registros en MÚLTIPLES tablas que deben ser consistentes entre sí
  • Transferencias de saldo, creación de pedido + items, registro de usuario + perfil
  • Cualquier operación donde un fallo parcial dejaría datos inconsistentes
Evítalo cuando...
  • Para operaciones en una sola tabla. Un save() individual ya es atómico por sí mismo
  • Para queries de lectura (SELECT). Las transacciones tienen overhead y no aportan nada en lecturas
  • Transacciones muy largas que bloquean filas durante segundos. Mantén las transacciones cortas

10. select: controlar qué campos devuelves

No siempre vas a querer devolver todas las columnas. Especialmente cuando hay passwords, tokens internos o campos pesados:

// Solo algunos campos
const users = await this.usersRepository.find({
    select: ['id', 'name', 'email', 'role', 'createdAt'],
});

// En findOne
const user = await this.usersRepository.findOne({
    where: { id },
    select: ['id', 'name', 'email', 'active', 'role'],
});

Recuerda del post 7: la columna password tiene select: false, así que ya se excluye automáticamente de los find() normales. Pero si tienes otros campos sensibles o pesados (un campo bio de tipo text, por ejemplo), select explícito te da control fino filipino.


11. Existencia y conteo: exists() y count()

Dos métodos que parecen una castaña pero que se usan constantemente:

// ¿Existe un usuario con este email?
const emailTaken = await this.usersRepository.exists({
    where: { email: 'domin@domin.es' },
});
// emailTaken: boolean (true/false)

// ¿Cuántos admins activos hay?
const adminCount = await this.usersRepository.count({
    where: { role: UserRole.ADMIN, active: true },
});
// adminCount: number

exists() es más eficiente que findOne() cuando solo necesitas saber si el registro existe. Ejecuta SELECT 1 ... LIMIT 1 en vez de cargar toda la entidad.


12. El service completo

Vamos a fijarnos en los detalles:

// src/users/users.service.ts
import {
    Injectable,
    NotFoundException,
    ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, ILike, type FindOptionsWhere } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
import { type CreateUserDto } from './dto/create-user.dto';
import { type UpdateUserDto } from './dto/update-user.dto';
import { type FilterUsersDto } from './dto/filter-users.dto';
import { type PaginatedResult } from '../common/interfaces/paginated-result.interface';

@Injectable()
export class UsersService {
    constructor(
        @InjectRepository(User)
        private readonly usersRepository: Repository<User>,
    ) {}

    async create(createUserDto: CreateUserDto): Promise<User> {
        const emailTaken = await this.usersRepository.exists({
            where: { email: createUserDto.email },
        });

        if (emailTaken) {
            throw new ConflictException(
                `El email ${createUserDto.email} ya está registrado`,
            );
        }

        const user = this.usersRepository.create({
            ...createUserDto,
            password: await bcrypt.hash(createUserDto.password, 10),
        });

        const savedUser = await this.usersRepository.save(user);

        // No devolvemos el password hasheado
        const { password: _, ...result } = savedUser;
        return result as User;
    }

    async findAll(filterDto: FilterUsersDto): Promise<PaginatedResult<User>> {
        const { page, limit, search, active, role } = filterDto;
        const skip = (page - 1) * limit;

        const where: FindOptionsWhere<User> = {};

        if (search) {
            where.name = ILike(`%${search}%`);
        }

        if (active !== undefined) {
            where.active = active;
        }

        if (role) {
            where.role = role;
        }

        const [data, total] = await this.usersRepository.findAndCount({
            where,
            order: { createdAt: 'DESC' },
            skip,
            take: limit,
        });

        const totalPages = Math.ceil(total / limit);

        return {
            data,
            meta: {
                total,
                page,
                limit,
                totalPages,
                hasNextPage: page < totalPages,
                hasPreviousPage: page > 1,
            },
        };
    }

    async findOne(id: string): Promise<User> {
        const user = await this.usersRepository.findOne({
            where: { id },
        });

        if (!user) {
            throw new NotFoundException(`Usuario con id ${id} no encontrado`);
        }

        return user;
    }

    async update(id: string, updateUserDto: UpdateUserDto): Promise<User> {
        const user = await this.findOne(id);

        if (updateUserDto.email && updateUserDto.email !== user.email) {
            const emailTaken = await this.usersRepository.exists({
                where: { email: updateUserDto.email },
            });

            if (emailTaken) {
                throw new ConflictException(
                    `El email ${updateUserDto.email} ya está en uso`,
                );
            }
        }

        if (updateUserDto.password) {
            updateUserDto = {
                ...updateUserDto,
                password: await bcrypt.hash(updateUserDto.password, 10),
            };
        }

        const updatedUser = this.usersRepository.merge(user, updateUserDto);
        return this.usersRepository.save(updatedUser);
    }

    async remove(id: string): Promise<void> {
        const user = await this.findOne(id);
        await this.usersRepository.softRemove(user);
    }

    async restore(id: string): Promise<User> {
        const result = await this.usersRepository.restore(id);

        if (result.affected === 0) {
            throw new NotFoundException(
                `Usuario con id ${id} no encontrado o no está eliminado`,
            );
        }

        return this.findOne(id);
    }
}

Fíjate en los detalles:


13. El controller completo

// src/users/users.controller.ts
import {
    Controller,
    Get,
    Post,
    Patch,
    Delete,
    Body,
    Param,
    Query,
    ParseUUIDPipe,
    HttpCode,
    HttpStatus,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { FilterUsersDto } from './dto/filter-users.dto';
import { type User } from './entities/user.entity';
import { type PaginatedResult } from '../common/interfaces/paginated-result.interface';

@Controller('users')
export class UsersController {
    constructor(private readonly usersService: UsersService) {}

    @Post()
    @HttpCode(HttpStatus.CREATED)
    create(@Body() createUserDto: CreateUserDto): Promise<User> {
        return this.usersService.create(createUserDto);
    }

    @Get()
    findAll(@Query() filterDto: FilterUsersDto): Promise<PaginatedResult<User>> {
        return this.usersService.findAll(filterDto);
    }

    @Get(':id')
    findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> {
        return this.usersService.findOne(id);
    }

    @Patch(':id')
    update(
        @Param('id', ParseUUIDPipe) id: string,
        @Body() updateUserDto: UpdateUserDto,
    ): Promise<User> {
        return this.usersService.update(id, updateUserDto);
    }

    @Delete(':id')
    @HttpCode(HttpStatus.NO_CONTENT)
    remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
        return this.usersService.remove(id);
    }

    @Patch(':id/restore')
    restore(@Param('id', ParseUUIDPipe) id: string): Promise<User> {
        return this.usersService.restore(id);
    }
}

Detallitos:


14. Probando el CRUD completo

CRUD completo en acción 0 / 6
$
Pulsa para ejecutar el siguiente comando

15. Errores comunes y debugging

”Cannot query across many-to-one/one-to-many”

Estás intentando filtrar por una relación sin hacer join. Lo veremos en el post 9.

El password aparece en las respuestas

Comprueba que la columna password tiene select: false en la entidad. Si usas select explícito en alguna query, asegúrate de no incluir password.

save() hace INSERT en vez de UPDATE

save() decide por la presencia de id. Si haces save({ name: 'Domin' }) sin id, siempre es un INSERT. Asegúrate de que la entidad que pasas a save() tiene el id rellenado (por eso usamos merge() que copia sobre la entidad existente).

”EntityMetadataNotFoundError”

La entidad no está registrada. Comprueba:

  1. TypeOrmModule.forFeature([User]) en el módulo.
  2. autoLoadEntities: true en TypeOrmModule.forRootAsync().

El soft delete no filtra los registros borrados

Comprueba que la entidad tiene @DeleteDateColumn(). Sin este decorador, softRemove() funciona pero las queries normales no filtran los registros borrados automáticamente.


16. Recapitulando

📦 Repository<Entity>

@InjectRepository(User) te da un repositorio tipado. find, findOne, save, remove, count, exists... todo built-in.

✍️ create() + save()

Siempre create() para instanciar y save() para persistir. Nunca save() directo con un objeto plano.

🔄 merge() + save()

Para updates: merge() copia las props del DTO sobre la entidad existente. save() detecta el id y hace UPDATE.

📄 Paginación tipada

findAndCount() + PaginatedResult genérico. meta con total, page, totalPages, hasNext, hasPrevious.

🗑️ Soft Delete

softRemove() + @DeleteDateColumn(). Los registros "borrados" se ocultan automáticamente. restore() para recuperar.

🔒 Transacciones

QueryRunner con connect/start/commit/rollback/release. try/catch/finally obligatorio. Para operaciones multi-tabla.

En el próximo post completamos el bloque de base de datos con Relaciones, Migraciones y Seeders. Vamos a conectar entidades entre sí con @OneToMany, @ManyToOne, @ManyToMany, montar migraciones con TypeORM CLI para dejar de depender de synchronize, y crear scripts de seed para tener datos de prueba.

EA, nos vemos en los bares!! 🍺


Pon a prueba lo aprendido

1. ¿Cuál es la diferencia entre create() y save() del Repository?

2. ¿Qué método del Repository devuelve los datos Y el total de registros para paginación?

3. ¿Qué hace softRemove() a diferencia de remove()?

4. En una transacción con QueryRunner, ¿por qué es CRÍTICO el bloque finally con release()?

5. ¿Por qué se usa merge() + save() para updates en vez de update() directo?