Parte V · Integración

18. Integración full-stack: del clic al COMMIT

Hasta aquí has estudiado las tres tecnologías por separado: Angular en la Parte II, NestJS en la Parte III y MikroORM en la Parte IV. El problema es que ninguna aplicación real vive en una sola de esas cajas: una funcionalidad atraviesa las tres, más la red y la base de datos, y es en las costuras donde aparecen los fallos caros. Este capítulo cose las costuras. Seguiremos una sola aplicación, TaskFlow (gestor de tareas de equipo), y recorreremos un caso de uso completo con todo el código, sin saltarnos ni una capa: del clic del usuario al COMMIT en PostgreSQL, y de vuelta.

COREANGULARNESTJSMIKROORM Tiempo de lectura: ~120 min Prerrequisitos: capítulos 4, 6, 9, 10, 12, 15 y 17

18.1 Qué vas a poder hacer al terminar

Este capítulo no introduce ninguna tecnología nueva. Su objetivo es que dejes de ver «una app Angular» y «una API Nest» y empieces a ver un solo sistema con un contrato en medio.

El dominio que usaremos TaskFlow gestiona tareas de equipo: User, Team, Project (de un equipo), Task (de un proyecto), Tag (N:M con la tarea), Comment y Attachment. Todo el código del capítulo encaja entre sí: si defines un DTO en Nest, lo verás con el mismo nombre y la misma forma en Angular.

18.2 Arquitectura de la solución completa

Una aplicación full-stack no es «dos aplicaciones que hablan». Es una cadena de responsabilidades donde cada eslabón hace una cosa y confía en el anterior solo hasta cierto punto. La regla que gobierna el diseño es fácil de enunciar y difícil de respetar:

Regla fundamental de la integración El frontend no es una fuente de autoridad. Todo lo que hace Angular (validar, ocultar botones, proteger rutas) es ergonomía: mejora la experiencia y ahorra viajes al servidor. La autoridad real —validación, autorización, invariantes, integridad— vive siempre en el backend y en el esquema de la base de datos. Un cliente HTTP hostil no ejecuta tu JavaScript.

18.2.1 El mapa completo

┌──────────────────────────────────────────────────────────────────────────────────┐
│  NAVEGADOR · ANGULAR                                                             │
│    Plantilla + componente (OnPush)  presentar y capturar eventos                 │
│    Señales / store                  estado de la INTERFAZ (no del dominio)       │
│    Servicio de API (HttpClient)     traducir DTO ↔ HTTP, nada más                │
│    Interceptores                    token, requestId, errores, reintentos        │
│    Guard de ruta (CanActivateFn)    navegación; NO es seguridad                  │
└─────────────────────────────────┬────────────────────────────────────────────────┘
                    HTTPS · JSON · Authorization: Bearer <jwt>
                    CORS (preflight OPTIONS si el origen es cruzado)
                                  ▼
┌──────────────────────────────────────────────────────────────────────────────────┐
│  NESTJS · orden de ejecución de una petición, de fuera hacia dentro               │
│   1 · MIDDLEWARE     helmet, requestId, RequestContext de MikroORM               │
│   2 · GUARDS         ¿quién eres? (JWT)   ¿puedes? (propiedad, rol)              │
│   3 · INTERCEPTORES  (antes) logging, timeout, cabeceras                         │
│   4 · PIPES          ValidationPipe: forma y tipos del DTO de entrada            │
│   5 · CONTROLADOR    traducir HTTP ↔ comando. CERO lógica de negocio             │
│   6 · CASO DE USO    orquestar + transacción. El «guion» de la operación         │
│   7 · DOMINIO        entidades con invariantes y reglas de negocio               │
│   8 · REPOSITORIO    acceso a datos a través del EntityManager                   │
│   3'· INTERCEPTORES  (después) serialización, envoltorio de respuesta            │
│   F · FILTRO GLOBAL  cualquier excepción → cuerpo de error único                 │
└─────────────────────────────────┬────────────────────────────────────────────────┘
                                  ▼
┌──────────────────────────────────────────────────────────────────────────────────┐
│  MIKROORM                                                                        │
│    EntityManager POR PETICIÓN  (RequestContext sobre AsyncLocalStorage)          │
│    Identity Map                una fila = un objeto dentro de la petición        │
│    Unit of Work                acumula cambios; flush() los ordena y emite       │
│    Change tracking             compara con la instantánea original               │
│    Bloqueo optimista           columna version → UPDATE … WHERE version = ?      │
└─────────────────────────────────┬────────────────────────────────────────────────┘
                       SQL parametrizado dentro de una transacción
                                  ▼
┌──────────────────────────────────────────────────────────────────────────────────┐
│  POSTGRESQL  NOT NULL / UNIQUE / FK / CHECK · índices · ACID                      │
│              La última línea de defensa. Si aquí no está, no es una regla.        │
└──────────────────────────────────────────────────────────────────────────────────┘

18.2.2 Qué hace y qué NO debe hacer cada capa

CapaSu responsabilidadLo que NUNCA debe hacer
Componente AngularRenderizar estado, capturar eventos, delegar en un store o servicio.Llamar a HttpClient, construir URLs, contener reglas de negocio o conocer el formato de error de la API.
Store de señalesSer la fuente de verdad de la interfaz: colección, filtros, carga, error.Ser la fuente de verdad del dominio. El servidor manda; el store es una caché con la que se reconcilia.
Servicio de APIUn método por endpoint, tipado con los DTO compartidos.Mostrar toasts, navegar, guardar estado o transformar el modelo.
Interceptor AngularTransversales: token, X-Request-Id, normalización de errores, refresco.Lógica de un endpoint concreto o if sobre URLs de negocio.
Middleware NestLo que debe existir antes de resolver la ruta: contexto del ORM, cabeceras, correlación.Autorizar: para eso están los guards, que sí conocen el handler y el contexto de ejecución.
GuardDecidir sí/no sobre autenticación y autorización.Modificar datos, cargar agregados enteros o ejecutar lógica de negocio.
Pipe de validaciónComprobar forma: tipos, obligatoriedad, rangos, formatos.Consultar la base de datos para validar reglas (unicidad, existencia, permisos).
ControladorMapear ruta, cuerpo y usuario a un comando; devolver un DTO y un código HTTP.Inyectar el EntityManager, abrir transacciones o decidir reglas de negocio.
Caso de usoOrquestar el escenario completo, delimitar la transacción, publicar eventos tras el commit.Conocer HTTP (nada de Request, Response ni excepciones de transporte).
Entidad de dominioCustodiar sus invariantes: «una tarea completada no vuelve a en curso».Hacer consultas, conocer DTOs o depender del framework HTTP.
MikroORMPersistir el grafo, detectar cambios, ordenar los INSERT/UPDATE.Sustituir a las restricciones de la base de datos: el ORM no defiende solo de la concurrencia.
PostgreSQLIntegridad referencial, unicidad, atomicidad, aislamiento. La verdad definitiva.Contener lógica de aplicación difusa en triggers que nadie sabe que existen.
Analogía: el aeropuerto El mostrador de facturación (Angular) comprueba que llevas billete y que la maleta no pesa: te ahorra el viaje inútil, pero no es seguridad. El control de pasaportes (guard JWT) decide quién eres; el de seguridad (validación y autorización), qué puedes llevar; la puerta de embarque (caso de uso) coordina que subas al avión correcto. Y solo la torre de control (la base de datos) puede garantizar que dos aviones no ocupen la misma pista. Quitar cualquier control «porque el mostrador ya lo miró» es exactamente el error que estamos evitando.

18.3 Recorrido completo: «el usuario marca una tarea como completada»

Esta es la sección central del capítulo. Seguiremos una sola interacción por todas las piezas, con el código real de cada una. El caso de uso es simple a propósito: lo interesante es el recorrido, no el negocio.

18.3.1 Diagrama de secuencia

USUARIO  COMPONENTE   STORE     API SVC  INTERCEPT.   RED     NEST     MIKROORM   PG
   │ clic ✔   │         │          │         │         │       │          │       │
   ├─────────►│ cambiarEstado(id,'done')     │         │       │          │       │
   │          ├────────►│ (1) UI OPTIMISTA: la tarea ya se ve tachada     │       │
   │◄─────────┴─────────┤ patch(dto)         │         │       │          │       │
   │                    ├─────────►│ +Authorization    │       │          │       │
   │                    │          ├────────►│ PATCH /api/v1/tasks/:id/status     │
   │                    │          │         ├────────►│ (2) middleware requestId │
   │                    │          │         │         │ (3) RequestContext (fork)│
   │                    │          │         │         │ (4) JwtAuthGuard         │
   │                    │          │         │         │ (5) TaskOwnershipGuard   │
   │                    │          │         │         │ (6) ValidationPipe       │
   │                    │          │         │         │ (7) Controller → caso    │
   │                    │          │         │         ├──────►│ BEGIN            │
   │                    │          │         │         │       ├─────────►│ SELECT│
   │                    │          │         │         │       │ cambiarEstado()  │
   │                    │          │         │         │       │ flush()          │
   │                    │          │         │         │       ├─────────►│ UPDATE│
   │                    │          │         │         │       │          │ COMMIT│
   │                    │          │         │◄────────┤ 200 + TaskDto    │       │
   │                    │          │◄────────┤ (8) interceptor de errores (pasa)  │
   │                    │◄─────────┤ TaskDto │         │       │          │       │
   │◄───────────────────┤ (9) RECONCILIACIÓN: version y updatedAt reales  │       │
   │ ── CAMINO DE ERROR ─────────────────────────────────────────────────────────│
   │ (10) fallo en cualquier punto → filtro global → cuerpo de error único →      │
   │      interceptor Angular → el store REVIERTE el cambio optimista → toast     │

18.3.2 Paso 0: el contrato compartido

Antes que el componente existe el contrato. Este archivo vive en libs/shared y lo importan los dos lados: es el ancla de todo el capítulo.

libs/shared/src/lib/task.contract.ts
// Sin dependencias de Angular, Nest, Node ni ORM. Solo TypeScript puro.
export const TASK_STATUSES = ['todo', 'in_progress', 'done'] as const;
export type TaskStatus = (typeof TASK_STATUSES)[number];

/** Lo que la API DEVUELVE. Es un contrato público: cambiarlo rompe clientes. */
export interface TaskDto {
  id: string; title: string; description: string | null;
  status: TaskStatus; projectId: string;
  assignee: { id: string; name: string } | null;
  tags: { id: string; name: string; color: string }[];
  dueDate: string | null;                  // ISO 8601 en UTC, nunca un Date
  completedAt: string | null; updatedAt: string;
  version: number;                         // bloqueo optimista (18.13)
}

/** Lo que la API ACEPTA al cambiar el estado. */
export interface UpdateTaskStatusDto { status: TaskStatus; version: number }

// Reglas puras compartidas por las dos capas: una sola definición.
export const TASK_TITLE_MIN = 3;
export const TASK_TITLE_MAX = 200;

export function esTransicionValida(desde: TaskStatus, hasta: TaskStatus): boolean {
  if (desde === hasta) return false;
  return !(desde === 'done' && hasta === 'in_progress');   // reabrir vuelve a 'todo'
}

18.3.3 Paso 1: el componente Angular

El componente no sabe qué es HTTP. Recibe una tarea, pinta una casilla y avisa al store. Fíjate en que la plantilla lee task().status directamente: no guarda una copia local del estado, porque duplicar estado es la causa número uno de interfaces que se desincronizan.

apps/web/src/app/tasks/task-item.component.ts
@Component({
  selector: 'tf-task-item',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <li class="task" [class.task--done]="completada()">
      <input type="checkbox" [id]="'task-' + task().id" [checked]="completada()"
             [disabled]="guardando()" (change)="alternar($event)" />
      <label [for]="'task-' + task().id">{{ task().title }}</label>
      @if (guardando()) { <span aria-live="polite">Guardando…</span> }
    </li>`,
})
export class TaskItemComponent {
  readonly task = input.required<TaskDto>();          // entrada de solo lectura
  private readonly store = inject(TaskStore);
  protected readonly completada = computed(() => this.task().status === 'done');
  // El store expone qué ids están en vuelo; así no duplicamos estado aquí.
  protected readonly guardando = computed(() => this.store.enVuelo().has(this.task().id));

  protected alternar(evento: Event): void {
    const marcada = (evento.target as HTMLInputElement).checked;
    void this.store.cambiarEstado(this.task().id, marcada ? 'done' : 'todo');
  }
}
Por qué la casilla no usa enlace bidireccional Con [(ngModel)] la casilla se marca sola y pasa a ser la fuente de verdad; si la petición falla, la casilla y el modelo dicen cosas distintas. Aquí es un reflejo de task().status: si el store revierte, la casilla se desmarca sola sin una línea extra de código.

18.3.4 Paso 2: el store, la actualización optimista y la reversión

El store hace tres cosas: aplica el cambio antes de que llegue la respuesta, llama al servicio de API y, si falla, revierte. La reversión restaura el estado exacto anterior, no «hace lo contrario»: si entretanto llegó otro cambio, invertir a ciegas corrompe la lista.

apps/web/src/app/tasks/task.store.ts
@Injectable({ providedIn: 'root' })
export class TaskStore {
  private readonly api = inject(TasksApi);
  private readonly toasts = inject(ToastService);
  private readonly _tasks = signal<readonly TaskDto[]>([]);
  private readonly _enVuelo = signal<ReadonlySet<string>>(new Set());
  readonly tasks = this._tasks.asReadonly();
  readonly enVuelo = this._enVuelo.asReadonly();
  readonly pendientes = computed(() => this._tasks().filter((t) => t.status !== 'done').length);

  async cambiarEstado(id: string, status: TaskStatus): Promise<void> {
    const original = this._tasks();                          // instantánea para revertir
    const actual = original.find((t) => t.id === id);
    if (!actual || actual.status === status) return;
    // (1) OPTIMISMO: pintamos el resultado esperado ya mismo.
    this.parchear(id, (t) => ({ ...t, status,
      completedAt: status === 'done' ? new Date().toISOString() : null }));
    this.marcarEnVuelo(id, true);
    try {
      // (2) La versión que enviamos es la que el usuario TENÍA al pulsar.
      const confirmada = await this.api.cambiarEstado(id, { status, version: actual.version });
      // (3) RECONCILIACIÓN: la respuesta sustituye a nuestra suposición; trae la
      //     version y el updatedAt reales, que no podíamos adivinar.
      this.parchear(id, () => confirmada);
    } catch (e) {
      this._tasks.set(original);                             // (4) REVERSIÓN exacta
      await this.resolverConflicto(e as ApiError, id, status);
    } finally { this.marcarEnVuelo(id, false); }
  }

  private parchear(id: string, fn: (t: TaskDto) => TaskDto): void {
    this._tasks.update((l) => l.map((t) => (t.id === id ? fn(t) : t)));
  }

  private marcarEnVuelo(id: string, activo: boolean): void {
    this._enVuelo.update((set) => {
      const copia = new Set(set);        // nueva referencia: la señal notifica
      activo ? copia.add(id) : copia.delete(id);
      return copia;
    });
  }
}

18.3.5 Paso 3: el servicio de API y el interceptor de autorización

Una capa finísima y aburrida a propósito: un método por endpoint, tipos del contrato compartido, ninguna decisión. Por encima, el interceptor añade la credencial sin que ningún servicio se entere.

tasks/tasks.api.ts · core/auth.interceptor.ts · app.config.ts
@Injectable({ providedIn: 'root' })
export class TasksApi {
  private readonly http = inject(HttpClient);
  private readonly base = `${environment.apiUrl}/v1/tasks`;

  obtener = (id: string): Promise<TaskDto> =>
    firstValueFrom(this.http.get<TaskDto>(`${this.base}/${id}`));
  // El tipo del cuerpo y el de la respuesta salen los dos de @taskflow/shared.
  cambiarEstado = (id: string, dto: UpdateTaskStatusDto): Promise<TaskDto> =>
    firstValueFrom(this.http.patch<TaskDto>(`${this.base}/${id}/status`, dto));
}

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).accessToken();     // señal con el token en memoria
  // Nunca adjuntes el token a peticiones ajenas: enviarías credenciales a un tercero.
  const esNuestraApi = req.url.startsWith(environment.apiUrl) || req.url.startsWith('/api/');
  if (!token || !esNuestraApi) return next(req);
  return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};

// EL ORDEN IMPORTA: la petición los recorre de arriba abajo; el error, al revés.
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor]))

18.3.6 Paso 4: la red, y por qué PATCH y no PUT

petición HTTP en el cable
PATCH /api/v1/tasks/7c9a1f3e-2b4d-4f61-9a0c-1d2e3f4a5b6c/status HTTP/1.1
Host: api.taskflow.example
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-Request-Id: 3f1c8a92-5e77-4a10-b3d5-9c0e2f8a1b44
Origin: https://app.taskflow.example

{"status":"done","version":7}
AspectoPUTPATCH
SemánticaSustituye el recurso completo por el cuerpoAplica una modificación parcial
Campos ausentes«Bórralos» o «déjalos por defecto»«No los toques»
IdempotenciaIdempotente por definiciónNo necesariamente (el nuestro sí lo es)
En nuestro casoObligaría a enviar título, descripción y etiquetas solo para marcar una casilla, con riesgo de pisar cambios ajenosEnviamos dos campos y no tocamos nada más

Además la ruta es /tasks/:id/status y no /tasks/:id: un subrecurso de acción acotada permite autorizar y auditar solo el cambio de estado y deja claro en la URL qué se modifica. Para un formulario de edición completo sí usaríamos PATCH /tasks/:id con un DTO más amplio.

18.3.7 Paso 5: el pipeline de Nest

core/request-id.middleware.ts · app.module.ts · tasks/task-ownership.guard.ts
export const almacenContexto = new AsyncLocalStorage<{ requestId: string }>();

export function requestIdMiddleware(req: Request, res: Response, next: NextFunction): void {
  // Respetamos el id del cliente o del balanceador si viene; si no, lo creamos.
  const requestId = (req.header('x-request-id') ?? randomUUID()).slice(0, 64);
  res.setHeader('X-Request-Id', requestId);
  // AsyncLocalStorage propaga el contexto a través de await, timers y promesas sin
  // pasarlo por parámetro: es lo mismo que hace RequestContext de MikroORM.
  almacenContexto.run({ requestId }, () => next());
}

// @mikro-orm/nestjs registra su middleware, que envuelve cada petición en un
// RequestContext (EM forkeado). Sin él, todas compartirían el Identity Map. Desastre.
@Module({ imports: [MikroOrmModule.forRoot()] })
export class AppModule implements NestModule {
  configure(c: MiddlewareConsumer): void { c.apply(requestIdMiddleware).forRoutes('*'); }
}

/** Autorización a nivel de recurso: no basta con estar autenticado. */
@Injectable()
export class TaskOwnershipGuard implements CanActivate {
  constructor(private readonly em: EntityManager) {}
  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest();
    const taskId: string | undefined = req.params?.id;
    if (!taskId) return true;
    // Consulta mínima: comprobamos pertenencia, no cargamos el agregado.
    const puede = await this.em.count(Task, {
      id: taskId, project: { team: { members: { id: req.user.id } } } });
    // 403 y no 404: si prefieres no filtrar la existencia, devuelve 404 (ver la FAQ).
    if (!puede) throw new ForbiddenException('No perteneces al equipo de esta tarea');
    return true;
  }
}
dto/update-task-status.dto.ts · main.ts · tasks.controller.ts
// La clase IMPLEMENTA el contrato compartido: si alguien cambia el tipo en
// libs/shared, esta clase deja de compilar. El contrato no puede desincronizarse.
export class UpdateTaskStatusDto implements Contrato {
  @IsIn(TASK_STATUSES as readonly string[], { message: 'estado no permitido' })
  status!: TaskStatus;
  @Type(() => Number) @IsInt({ message: 'la versión debe ser un entero' }) @Min(1)
  version!: number;
}

app.useGlobalPipes(new ValidationPipe({
  whitelist: true,              // elimina propiedades no declaradas en el DTO
  forbidNonWhitelisted: true,   // y falla si llegan: detecta clientes desactualizados
  transform: true,              // instancia la clase (necesario para @Type)
  transformOptions: { enableImplicitConversion: false },
}));

@Controller({ path: 'tasks', version: '1' })
@UseGuards(JwtAuthGuard, TaskOwnershipGuard)      // se ejecutan en este orden
export class TasksController {
  constructor(private readonly cambiarEstado: CambiarEstadoTareaUseCase) {}
  // El controlador solo traduce HTTP → comando y devuelve el DTO. Nada más.
  @Patch(':id/status')
  patchStatus(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTaskStatusDto,
              @CurrentUser() usuario: AuthUser): Promise<TaskDto> {
    return this.cambiarEstado.ejecutar({ taskId: id, ...dto, actorId: usuario.id });
  }
}

18.3.8 Paso 6: el caso de uso, el dominio y MikroORM

apps/api/src/tasks/task.entity.ts (extracto)
@Entity({ tableName: 'tasks' })
export class Task {
  @PrimaryKey({ type: 'uuid' }) id: string = randomUUID();
  @Property({ length: TASK_TITLE_MAX }) title!: string;
  @Property({ type: 'text', nullable: true }) description: string | null = null;
  // items acepta el array del contrato compartido: una sola definición del enum.
  @Enum({ items: () => TASK_STATUSES as unknown as string[], nativeEnumName: 'task_status' })
  status: TaskStatus = 'todo';
  @ManyToOne(() => Project, { ref: true }) project!: Ref<Project>;
  @ManyToOne(() => User, { ref: true, nullable: true }) assignee: Ref<User> | null = null;
  @ManyToMany(() => Tag) tags = new Collection<Tag>(this);
  @OneToMany(() => Comment, (c) => c.task) comments = new Collection<Comment>(this);
  @Property({ nullable: true }) completedAt: Date | null = null;
  @Property({ onUpdate: () => new Date() }) updatedAt: Date = new Date();
  /** Bloqueo optimista: MikroORM añade WHERE version = ? a cada UPDATE. */
  @Property({ version: true }) version!: number;
  // La invariante vive AQUÍ, no en el controlador ni en el servicio.
  cambiarEstado(nuevo: TaskStatus, actorId: string): void {
    if (!esTransicionValida(this.status, nuevo)) throw new TransicionInvalidaError();  // → 422
    this.status = nuevo;
    this.completedAt = nuevo === 'done' ? new Date() : null;
    this.lastActorId = actorId;
  }
}
apps/api/src/tasks/use-cases/cambiar-estado-tarea.use-case.ts
@Injectable()
export class CambiarEstadoTareaUseCase {
  constructor(private readonly em: EntityManager, private readonly eventos: TaskEventsPublisher) {}
  async ejecutar(cmd: CambiarEstadoCommand): Promise<TaskDto> {
    // La transacción abarca TODO el caso de uso: si algo falla, no queda nada a medias.
    const dto = await this.em.transactional(async (em) => {
      const task = await em.findOneOrFail(Task, { id: cmd.taskId },
        { populate: ['assignee', 'tags'] });         // lo que el DTO necesita: sin N+1
      // Si la versión no coincide → OptimisticLockError → 409.
      em.lock(task, LockMode.OPTIMISTIC, { lockVersion: cmd.version });
      task.cambiarEstado(cmd.status, cmd.actorId);   // invariantes de dominio
      // flush() AQUÍ: el DTO necesita version y updatedAt ya aplicados.
      await em.flush();
      return TaskMapper.aDto(task);
    });
    // Efectos externos FUERA y DESPUÉS del commit: si difundes antes, anuncias
    // un cambio que quizá se deshaga.
    this.eventos.publicarCambioDeEstado(dto);
    return dto;
  }
}
Qué ocurre exactamente dentro de flush() El Unit of Work compara el estado actual de cada entidad gestionada con la instantánea que guardó al cargarla (change set). Solo emite los UPDATE de las columnas que cambiaron de verdad, ordena las operaciones para respetar las claves ajenas y las agrupa. Como la entidad tiene @Property({ version: true }), el UPDATE lleva la condición de versión y MikroORM comprueba el número de filas afectadas.
SQL efectivamente ejecutado (PostgreSQL)
BEGIN;
select "t0".*, "a1"."id" as "a1__id", "a1"."name" as "a1__name"
  from "tasks" as "t0" left join "users" as "a1" on "t0"."assignee_id" = "a1"."id"
 where "t0"."id" = $1 limit 1;
select "t1".*, "t0"."task_id" as "fk__task_id" from "task_tags" as "t0"
 inner join "tags" as "t1" on "t0"."tag_id" = "t1"."id" where "t0"."task_id" in ($1);
-- flush(): solo las columnas que cambiaron + la condición de versión.
update "tasks" set "status" = $1, "completed_at" = $2, "updated_at" = $3,
       "last_actor_id" = $4, "version" = "version" + 1
 where "id" = $5 and "version" = $6;
-- Si esto afecta a 0 filas → OptimisticLockError → 409 (ver 18.13).
COMMIT;
apps/api/src/tasks/task.mapper.ts
export const TaskMapper = {
  // Frontera explícita entidad → DTO: añadir una columna NO la publica sola.
  aDto: (task: Task): TaskDto => ({
    id: task.id, title: task.title, description: task.description, status: task.status,
    projectId: task.project.id,                  // Ref: el id sin cargar el proyecto
    assignee: task.assignee ? { id: task.assignee.$.id, name: task.assignee.$.name } : null,
    tags: task.tags.getItems().map((t) => ({ id: t.id, name: t.name, color: t.color })),
    dueDate: task.dueDate?.toISOString() ?? null,
    completedAt: task.completedAt?.toISOString() ?? null,
    updatedAt: task.updatedAt.toISOString(), version: task.version,
  }),
};

18.3.9 El camino de error en cada punto

Dónde fallaQué ocurre técnicamenteQué ve el usuario
Sin conexiónHttpErrorResponse con status: 0La casilla se revierte y aparece «Sin conexión. Comprueba tu red»
TimeoutOperador timeout(15000) en el interceptor«El servidor tarda demasiado. Inténtalo de nuevo»
Token caducado401 → el interceptor refresca y reintenta una sola vezNada si el refresco funciona; si no, vuelve al login conservando la URL
TaskOwnershipGuard403 FORBIDDEN«No tienes permiso sobre esta tarea» y la casilla vuelve a su sitio
ValidationPipe400 VALIDATION_FAILED con detailsMensaje genérico: indica un bug del cliente, no un error del usuario
findOneOrFailNotFoundError → 404 NOT_FOUND«Esta tarea ya no existe» y se elimina de la lista
Invariante de dominioTransicionInvalidaError → 422«No se puede pasar de completada a en curso; reábrela primero»
em.lockOptimisticLockError → 409 VERSION_CONFLICT«Otra persona modificó esta tarea» y se recarga sola
Base de datos caídaDriverException → 500 INTERNAL«Error inesperado. Referencia: 3f1c8a92…», copiable para soporte

18.4 Contratos compartidos entre frontend y backend

18.4.1 El problema: la deriva silenciosa

El día uno el backend define TaskDto y el frontend escribe una interfaz igual. Todo funciona. El mes tres alguien renombra dueDate a deadline en el backend; el frontend sigue compilando perfectamente porque su interfaz local sigue diciendo dueDate. En producción la fecha aparece vacía y nadie lo nota hasta que un cliente escribe. Esa es la característica más peligrosa de duplicar tipos: la desincronización no produce ningún error de compilación.

web/src/app/models/task.tsINCORRECTO
// Copiado a mano desde el backend en marzo.
// Nadie recuerda de dónde salió ni quién lo mantiene.
export interface Task {
  id: string;
  title: string;
  dueDate: string;   // el backend lo renombró a deadline en junio
  done: boolean;     // el backend usa status: 'todo'|'in_progress'|'done'
  // falta version, añadido para el bloqueo optimista
}

// Compila y pasa los tests (con mocks de ESTA forma). Falla en producción,
// en silencio, con undefined.
libs/shared/src/lib/task.contract.tsCORRECTO
// ÚNICA definición. La importan apps/web y apps/api.
export interface TaskDto {
  id: string;
  title: string;
  status: TaskStatus;
  dueDate: string | null;
  version: number;
}

// El DTO de Nest la implementa: class TaskResponseDto implements TaskDto {…}
// Si el contrato cambia, los dos lados dejan de compilar en CI: minutos, no meses.

18.4.2 Las cuatro estrategias

EstrategiaCómo funcionaA favorEn contraCuándo
Librería compartida en monorepoUn paquete TypeScript que importan las dos aplicacionesCoste cero, sin generación, refactor seguro con el IDE, admite constantes y funciones purasExige monorepo; tienta a meter cosas que no deberían compartirseOpción por defecto si controlas los dos lados
Cliente generado desde OpenAPINest publica el esquema con @nestjs/swagger y un generador produce tipos y funcionesSirve para clientes en otros lenguajes y equipos separados; el esquema es el contrato oficialPaso de build extra; sin buenas anotaciones genera anyAPI pública, repositorios separados o consumidores heterogéneos
tRPCLos tipos del servidor se infieren en el cliente, sin esquema intermedioSeguridad de tipos extremo a extremo sin generaciónEncaja mal aquí (ver el aviso)Proyectos Next.js o Node+React con enrutado funcional
Copiar a manoDuplicar la interfaz en el frontendNinguna que compenseDeriva silenciosa garantizadaNunca, salvo consumir una API ajena (y entonces valida en runtime)
Por qué tRPC no encaja con Nest + Angular clásico tRPC invierte el modelo: no hay controladores REST ni DTOs, hay procedimientos cuyo tipo de retorno viaja al cliente por inferencia de TypeScript. Eso choca con el diseño de NestJS —decoradores, guards por handler, pipeline HTTP explícito— y con Angular, cuyo HttpClient, interceptores y caché se apoyan en peticiones HTTP nombradas. Podrías montarlo en un adaptador de Nest, pero perderías guards, interceptores, filtros y OpenAPI, es decir, la razón por la que elegiste Nest. En un monorepo TypeScript, libs/shared da el 90 % del beneficio sin renunciar a nada.

18.4.3 libs/shared en la práctica

libs/shared/
├── src/index.ts                    ← superficie pública: solo lo exportado es contrato
├── src/lib/task.contract.ts        TaskDto · UpdateTaskStatusDto · TaskStatus · reglas puras
├── src/lib/project.contract.ts     ProjectDto · CreateProjectDto
├── src/lib/user.contract.ts        UserDto · LoginDto · AuthTokensDto
├── src/lib/pagination.contract.ts  Paginated<T> · TaskQuery
├── src/lib/api-error.contract.ts   ApiErrorBody · ApiErrorCode · FieldError
├── package.json                    name: "@taskflow/shared"
└── tsconfig.lib.json               lib: ["ES2022"]  ← SIN "dom" y SIN @types/node

  ┌────────────────┐    importa     ┌───────────────────┐    importa    ┌──────────────┐
  │  apps/web      │───────────────►│ @taskflow/shared  │◄──────────────│  apps/api    │
  │  (Angular)     │                │  (TypeScript puro)│               │  (NestJS)    │
  └────────────────┘                └───────────────────┘               └──────┬───────┘
                                              ▲                                ▼
        NUNCA al revés: shared no importa ────┘              entidades de MikroORM: dependen
        jamás de apps/*                                      de shared, pero shared no las ve
Qué NO debe entrar jamás en libs/shared

Nada del ORM: entidades, decoradores de MikroORM, Collection, Ref, EntityManager. Si Angular importa una entidad, arrastra @mikro-orm/core al bundle del navegador y el modelo de persistencia se convierte en tu contrato público.

Nada de Node: fs, crypto, process.env, Buffer. Configura el tsconfig sin @types/node para que el compilador lo impida.

Nada del DOM ni de Angular (HttpClient, @Injectable, window) y ningún secreto: todo lo que entra aquí acaba en el JavaScript que descarga cualquier visitante.

Lo que sí: tipos, interfaces, uniones de literales, constantes de validación, expresiones regulares, funciones puras y deterministas, y códigos de error.

Cómo impedir por herramienta que alguien rompa la regla Con Nx, etiqueta la librería ("tags": ["type:contract"]) y activa @nx/enforce-module-boundaries: cualquier importación de apps/api desde libs/shared falla el lint. Sin Nx, ESLint con import/no-restricted-paths consigue lo mismo. Una regla escrita en el README la incumple cualquiera un viernes; una regla en CI, no.

18.4.4 Generar un cliente TypeScript desde OpenAPI

apps/api/src/main.ts · terminal
const config = new DocumentBuilder()
  .setTitle('TaskFlow API').setVersion('1.0.0').addBearerAuth().build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);   // interfaz en /docs, JSON en /docs-json

// En CI conviene volcarlo a un fichero versionado para detectar cambios de contrato.
if (process.env.DUMP_OPENAPI) await writeFile('openapi.json', JSON.stringify(document, null, 2));
generación del cliente · tres alternativas reales
# A · tipos + cliente moderno
npx @hey-api/openapi-ts -i http://localhost:3000/docs-json -o apps/web/src/app/api-generated
# B · servicios Angular basados en HttpClient
npx ng-openapi-gen --input http://localhost:3000/docs-json --output apps/web/src/app/api-generated
# C · solo los TIPOS del esquema, sin cliente
npx openapi-typescript http://localhost:3000/docs-json -o apps/web/src/app/api.types.ts
Tres avisos sobre la generación

1. El código generado no se edita nunca: va a una carpeta marcada e ignorada por el linter. Si necesitas envolverlo, hazlo en un servicio propio.

2. Genéralo en CI y falla la construcción si difiere de lo versionado: así un cambio de contrato aparece como un diff que alguien revisa, no como una sorpresa.

3. La calidad del cliente depende de las anotaciones. Sin @ApiProperty, sin el plugin de CLI de @nestjs/swagger o sin tipos de retorno explícitos, el esquema sale lleno de object y el cliente, de any.

18.4.5 Versionado del contrato y compatibilidad hacia atrás

Tipo de cambio¿Rompe clientes?Cómo hacerlo
Añadir un campo opcional a la respuestaNoAdelante: los clientes antiguos lo ignoran
Añadir un campo obligatorio a la peticiónHazlo opcional con valor por defecto, o nueva versión
Renombrar un campoPublicar los dos, marcar el viejo como obsoleto y retirarlo tras un plazo anunciado
Ensanchar un tipo (nuevo valor en la unión)Sí, sutilmenteEl switch del cliente deja de ser exhaustivo: prevé una rama por defecto desde el principio
Eliminar un campo o endpointObsolescencia anunciada, métricas de uso y retirada en una versión mayor
apps/api/src/main.ts · versionado por URI
app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
// Rutas resultantes: /api/v1/tasks, /api/v2/tasks…
// El controlador declara @Controller({ path: 'tasks', version: '1' }).

Con dos versiones vivas, libs/shared exporta ambas formas (TaskDtoV1, TaskDtoV2) y un alias TaskDto = TaskDtoV2. El frontend migra cuando puede; el backend mantiene las dos hasta que las métricas de uso de la v1 caen a cero.

18.5 Mapeo entre capas: entidad, dominio, DTO y modelo de vista

Una misma «tarea» aparece con cinco formas a lo largo del recorrido. Es normal que esto genere rechazo, así que conviene justificarlo: cada forma existe porque tiene un motivo de cambio distinto, que es literalmente la definición del principio de responsabilidad única.

  ENTRADA                                                              SALIDA
  UpdateTaskStatusDto          ┌─────────────────┐          TaskDto (respuesta)
  (lo que el cliente ENVÍA)    │  CASO DE USO    │          (lo que la API PUBLICA)
        │  ValidationPipe      │                 │                    ▲ TaskMapper
        ▼                      │                 │                    │
  CambiarEstadoCommand ───────►│                 │──────► Task (entidad de dominio)
  (intención, sin HTTP)        └─────────────────┘        invariantes + estado
                                              MikroORM ──► fila de la tabla "tasks"
  Y en el navegador:
  TaskDto ──► TaskVm { titulo, etiquetaFecha, iconoEstado, esUrgente }  ← modelo de VISTA
FormaQuién manda en ellaPor qué cambia
Entidad ORMEl esquema de la base de datosMigraciones, índices, normalización, rendimiento
Modelo de dominioLas reglas de negocioCambia una política: «las tareas vencidas no se pueden completar»
DTO de entradaEl contrato público de escrituraCambia lo que un cliente puede pedir; es la superficie de ataque
DTO de salidaEl contrato público de lecturaCambia lo que exponemos; una columna interna nueva no debe filtrarse
Modelo de vistaLa interfaz concretaCambia el diseño, el idioma o el formato de fecha

18.5.1 Cuándo colapsar capas sin pecar de sobreingeniería

tasks.controller.tsINCORRECTO
@Get(':id')
async findOne(@Param('id') id: string) {
  return this.em.findOneOrFail(Task, id);   // devuelve la ENTIDAD
}

// Problemas, todos reales:
// 1. Publica cualquier columna nueva sin decidirlo (passwordHash, internalNotes,
//    deletedAt...): una migración se convierte en una fuga de datos.
// 2. La forma de la respuesta cambia con el populate de cada endpoint: las
//    relaciones no cargadas salen como {} o como el id. Contrato impredecible.
// 3. Una colección inicializada sale entera: 4000 comentarios en el JSON.
// 4. Ciclos task ↔ project ↔ tasks: JSON.stringify puede reventar.
// 5. El esquema de la base de datos se convierte en tu API pública.
tasks.controller.tsCORRECTO
@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<TaskDto> {
  const task = await this.em.findOneOrFail(Task, { id }, {
    populate: ['assignee', 'tags'],   // exactamente lo que el DTO necesita
  });
  return TaskMapper.aDto(task);       // frontera explícita y auditable
}

// El tipo de retorno Promise<TaskDto> pone al compilador a vigilar la frontera: si
// el mapper se desvía del contrato, CI falla. Y Swagger documenta con precisión.

18.5.2 Mapeadores manuales frente a librerías

EnfoqueVentajasInconvenientesVeredicto
Función manual (TaskMapper.aDto)Explícito, tipado al 100 %, sin magia, fácil de testear, coste nuloVerboso; hay que acordarse de tocarlo al añadir camposRecomendado. El «hay que acordarse» es la funcionalidad: obliga a decidir
ClassSerializerInterceptor con @Exclude/@ExposeIntegrado en Nest, poco código, útil para omitir campos sensiblesLista negra: lo que olvides excluir se publica. Se rompe con relaciones perezosasRed de seguridad adicional, nunca la única frontera
AutoMapper y similaresMenos repetición con decenas de mapeos casi idénticosConfiguración implícita, errores en runtime, tipado más débilSolo si el equipo ya lo domina y hay muchísimo mapeo trivial

18.6 Manejo de errores de extremo a extremo

Un sistema con un formato de error por endpoint es un sistema donde el frontend tiene un if por endpoint. La regla es: toda respuesta de error tiene exactamente la misma forma, sin excepción, incluidos los 500 y los de validación.

  ORIGEN DEL FALLO                 NORMALIZACIÓN                      CONSUMO
  class-validator ────┐      ┌───────────────────────┐
  (400 por campo)     │      │  ApiExceptionFilter   │
  Guard JWT ──────────┤      │  @Catch()  GLOBAL     │      ┌──────────────────┐
  (401 / 403)         ├─────►│  · código de negocio  │─────►│ errorInterceptor │
  findOneOrFail ──────┤      │    y estado HTTP      │      │    (Angular)     │
  (NotFoundError)     │      │  · añade requestId    │      │ · traduce código │
  em.lock ────────────┤      │  · registra en el log │      │ · elige destino  │
  (OptimisticLock)    │      │  · OCULTA el detalle  │      └────────┬─────────┘
  UniqueConstraint ───┤      │    interno si es 5xx  │               │
  Error de dominio ───┤      └───────────┬───────────┘      ┌────────┼────────┐
  (422)               │                  ▼                  ▼        ▼        ▼
  Excepción no ───────┘      { error: { code, message,    toast   campos   página
  prevista (500)                details, requestId,               del form de error
                                timestamp, path } }
  TRAZABILIDAD: el mismo requestId viaja en la cabecera X-Request-Id, en el cuerpo del
  error, en el log del servidor y en el mensaje al usuario. Soporte recibe «referencia
  3f1c8a92» y encuentra la traza exacta con un solo grep.
libs/shared/src/lib/api-error.contract.ts
export const API_ERROR_CODES = [
  'VALIDATION_FAILED',   // 400 · el cuerpo no cumple el DTO
  'UNAUTHENTICATED',     // 401 · falta el token o ha caducado
  'FORBIDDEN',           // 403 · autenticado pero sin permiso
  'NOT_FOUND',           // 404 · no existe (o no debes saber que existe)
  'CONFLICT',            // 409 · choque con el estado actual (unicidad)
  'VERSION_CONFLICT',    // 409 · concurrencia optimista: alguien te adelantó
  'UNPROCESSABLE',       // 422 · sintaxis correcta, regla de negocio incumplida
  'RATE_LIMITED',        // 429 · demasiadas peticiones
  'INTERNAL',            // 500 · fallo nuestro; nunca expongas el detalle
] as const;
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];

export interface FieldError {
  field: string;    // 'title', o 'assignee.id' para anidados
  code: string;     // 'minLength', 'isEmail'... estable, apto para traducir
  message: string;  // texto por defecto, por si no hay traducción
}

export interface ApiErrorBody {
  error: { code: ApiErrorCode; message: string; details?: FieldError[];
           requestId: string; timestamp: string; path: string };
}

18.6.1 El filtro global de Nest

apps/api/src/core/api-exception.filter.ts
@Catch()   // sin argumentos: captura absolutamente todo
export class ApiExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(ApiExceptionFilter.name);

  catch(exception: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const req = ctx.getRequest<Request>(), res = ctx.getResponse<Response>();
    const requestId = almacenContexto.getStore()?.requestId ?? 'sin-contexto';
    const { status, code, message, details } = this.traducir(exception);
    // El log SIEMPRE lleva el detalle completo; la respuesta, no.
    this.logger[status >= 500 ? 'error' : 'warn']({
      requestId, status, code, path: req.originalUrl, method: req.method,
      err: exception instanceof Error ? exception.message : exception,
    }, status >= 500 && exception instanceof Error ? exception.stack : undefined);
    const cuerpo: ApiErrorBody = { error: {
      code,
      message: status >= 500 ? 'Error interno del servidor' : message,   // nunca filtres el interno
      ...(details?.length ? { details } : {}),
      requestId, timestamp: new Date().toISOString(), path: req.originalUrl,
    }};
    res.setHeader('X-Request-Id', requestId);
    res.status(status).json(cuerpo);
  }

  private traducir(e: unknown): { status: number; code: ApiErrorCode; message: string; details?: FieldError[] } {
    // 1 · Errores de MikroORM: se traducen ANTES que HttpException.
    if (e instanceof OptimisticLockError) return { status: 409, code: 'VERSION_CONFLICT', message: 'El recurso ha cambiado desde que lo cargaste' };
    if (e instanceof UniqueConstraintViolationException) return { status: 409, code: 'CONFLICT', message: 'Ya existe un registro con esos datos' };
    if (e instanceof ForeignKeyConstraintViolationException) return { status: 409, code: 'CONFLICT', message: 'La operación viola una referencia existente' };
    if (e instanceof NotFoundError) return { status: 404, code: 'NOT_FOUND', message: 'Recurso no encontrado' };
    // 2 · Errores de dominio propios: 422, la sintaxis era correcta.
    if (e instanceof ErrorDeDominio) return { status: 422, code: 'UNPROCESSABLE', message: e.message };
    // 3 · Excepciones HTTP de Nest, incluida la del ValidationPipe.
    if (e instanceof HttpException) {
      const status = e.getStatus(), resp = e.getResponse();
      const bruto = typeof resp === 'string' ? { message: resp } : (resp as Record<string, unknown>);
      if (status === 400 && Array.isArray(bruto['details']))
        return { status, code: 'VALIDATION_FAILED', message: 'Los datos enviados no son válidos',
                 details: bruto['details'] as FieldError[] };
      const porEstado: Partial<Record<number, ApiErrorCode>> = {
        400: 'VALIDATION_FAILED', 401: 'UNAUTHENTICATED', 403: 'FORBIDDEN',
        404: 'NOT_FOUND', 409: 'CONFLICT', 422: 'UNPROCESSABLE', 429: 'RATE_LIMITED' };
      return { status, code: porEstado[status] ?? 'INTERNAL', message: String(bruto['message'] ?? e.message) };
    }
    return { status: 500, code: 'INTERNAL', message: 'Error interno del servidor' };  // 4 · fallo nuestro
  }
}
Códigos de campo estables, no textos El details que lee el filtro lo produce el exceptionFactory del ValidationPipe (lo implementamos en la solución del ejercicio 18.4), conservando el nombre de la restricción —minLength, isEmail— en lugar del texto generado. El frontend traduce el código, no el mensaje, y así la interfaz sigue funcionando en cualquier idioma aunque cambien los textos del backend.

18.6.2 El interceptor de Angular que lo consume

apps/web/src/app/core/api-error.ts
/** Error normalizado: todo el frontend trabaja SOLO con esta clase. */
export class ApiError extends Error {
  constructor(
    readonly code: ApiErrorCode | 'NETWORK' | 'TIMEOUT',
    readonly status: number,
    readonly requestId: string,
    readonly details: FieldError[] = [],
    mensaje = 'Se ha producido un error',
  ) { super(mensaje); this.name = 'ApiError'; }

  mensajeParaUsuario(): string {
    const textos: Record<string, string> = {
      NETWORK: 'Sin conexión. Comprueba tu red e inténtalo de nuevo.',
      TIMEOUT: 'El servidor tarda demasiado en responder.',
      UNAUTHENTICATED: 'Tu sesión ha caducado. Vuelve a iniciar sesión.',
      FORBIDDEN: 'No tienes permiso para realizar esta acción.',
      NOT_FOUND: 'El elemento ya no existe.',
      CONFLICT: 'La operación choca con datos existentes.',
      VERSION_CONFLICT: 'Otra persona ha modificado este elemento.',
      VALIDATION_FAILED: 'Revisa los datos del formulario.',
      RATE_LIMITED: 'Demasiadas peticiones. Espera unos segundos.',
      INTERNAL: 'Error inesperado. Ya estamos avisados.',
      UNPROCESSABLE: this.message,   // el servidor ya explica la regla incumplida
    };
    return textos[this.code] ?? this.message;
  }
}

export function desdeRespuesta(e: HttpErrorResponse): ApiError {
  const requestId = e.headers?.get('X-Request-Id') ?? 'desconocido';
  // status 0 = la petición ni siquiera llegó: red caída, DNS, o CORS bloqueado.
  if (e.status === 0) return new ApiError('NETWORK', 0, requestId);
  const cuerpo = e.error as Partial<ApiErrorBody> | null;
  if (cuerpo?.error?.code) {
    const { code, message, details, requestId: rid } = cuerpo.error;
    return new ApiError(code, e.status, rid ?? requestId, details ?? [], message);
  }
  // Error que NO sigue nuestro contrato (un 502 del reverse proxy, una página HTML).
  return new ApiError('INTERNAL', e.status, requestId, [], 'Respuesta inesperada del servidor');
}
error.interceptor.tsINCORRECTO
export const errorInterceptor: HttpInterceptorFn = (req, next) =>
  next(req).pipe(
    catchError((e) => {
      // 1. Toast para TODO: el formulario saca un toast inútil ADEMÁS de
      //    marcar los campos.
      toasts.error(e.message);
      // 2. EMPTY completa el observable sin valor: el llamante no entra en su
      //    catch, no revierte el cambio optimista y la interfaz se queda
      //    mintiendo. Es el bug más caro del capítulo.
      return EMPTY;
    }),
  );
error.interceptor.tsCORRECTO
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const toasts = inject(ToastService);
  const auth = inject(AuthService);
  return next(req).pipe(
    timeout({ each: 15_000 }),
    catchError((bruto: unknown) => {
      if (bruto instanceof TimeoutError) return throwError(() => new ApiError('TIMEOUT', 0, 'desconocido'));
      const error = desdeRespuesta(bruto as HttpErrorResponse);
      // 401 → un solo intento de refresco; luego se propaga igualmente.
      if (error.code === 'UNAUTHENTICATED' && !req.url.includes('/auth/')) {
        return auth.refrescarUnaVez().pipe(
          switchMap(() => next(req)),
          catchError(() => { auth.cerrarSesion(); return throwError(() => error); }),
        );
      }
      // Solo lo global sale como toast; lo específico lo decide quien llamó.
      if (['INTERNAL', 'NETWORK', 'TIMEOUT'].includes(error.code))
        toasts.error(error.mensajeParaUsuario(), { requestId: error.requestId });
      return throwError(() => error);   // SIEMPRE se propaga
    }),
  );
};
SituaciónHTTPCódigoQuién decideQué ve el usuario
Red caída, DNS o CORS bloqueado0NETWORKInterceptorToast persistente con botón «Reintentar»
Sin respuesta en 15 sTIMEOUTInterceptorToast; la operación se revierte
Token caducado401UNAUTHENTICATEDInterceptorNada si el refresco funciona; si no, login
Sin permiso403FORBIDDENLlamanteMensaje en contexto; el botón se deshabilita
Recurso borrado404NOT_FOUNDLlamanteSe quita de la lista con aviso
Otro usuario editó antes409VERSION_CONFLICTLlamanteAviso y recarga o fusión (18.13)
Regla de negocio incumplida422UNPROCESSABLELlamanteEl mensaje del servidor, tal cual
Excepción no prevista500INTERNALInterceptor«Error inesperado. Referencia: 3f1c8a92»

18.7 Autenticación de extremo a extremo

Aquí vemos el recorrido; el detalle criptográfico, la rotación de refresh tokens y las defensas contra CSRF y XSS están en el capítulo 12.

  FORMULARIO      AuthService        API /auth        JwtStrategy      RUTA PROTEGIDA
  1 ▸ submit ────►│ POST /auth/login │                │                  │
      │           ├─────────────────►│ valida bcrypt, firma JWT          │
      │           │◄─────────────────┤ 200 { accessToken, user } + Set-Cookie:
      │           │                  │ rt=…; HttpOnly; Secure; SameSite=Strict
  2 ▸ │           │ accessToken → señal EN MEMORIA (nunca en localStorage:
      │           │ cualquier XSS lo leería)                             │
  3 ▸ │◄──────────┤ router.navigate(urlPendiente ?? '/')                 │
  4 ▸ │           │ authGuard: ¿auth.autenticado()? ────────────────────►│ sí → entra
      │           │                                    no → /login?redirectTo=…
  5 ▸ │           │ authInterceptor añade Authorization: Bearer …        │
      │           ├─────────────────►│ JwtAuthGuard verifica firma, exp, │
      │           │                  │ iss y aud → req.user              │
  6 ▸ │  ── 401 ──┤ POST /auth/refresh (la cookie viaja sola) → rota el refresh,
      │           │ devuelve un access nuevo y reintenta la petición UNA vez
  7 ▸ │  logout   │ POST /auth/logout invalida el refresh en el servidor, borra
      │           │ la señal y navega a /login                           │
core/auth.service.ts · core/auth.guard.ts · api/auth/jwt.strategy.ts
@Injectable({ providedIn: 'root' })
export class AuthService {
  // El access token vive SOLO en memoria: al recargar se recupera con el refresh
  // de la cookie HttpOnly, y ningún XSS puede leerlo.
  private readonly _accessToken = signal<string | null>(null);
  private readonly _usuario = signal<UserDto | null>(null);
  readonly accessToken = this._accessToken.asReadonly();
  readonly autenticado = computed(() => this._accessToken() !== null);
  /** Evita la estampida: N peticiones que fallan a la vez comparten UN refresco. */
  private refrescoEnCurso: Observable<AuthTokensDto> | null = null;
  login(dto: LoginDto): Observable<AuthTokensDto> {
    // withCredentials para que el navegador acepte y guarde la cookie de refresco.
    return this.http.post<AuthTokensDto>('/api/v1/auth/login', dto, { withCredentials: true })
      .pipe(tap((r) => { this._accessToken.set(r.accessToken); this._usuario.set(r.user); }));
  }

  refrescarUnaVez(): Observable<AuthTokensDto> {
    this.refrescoEnCurso ??= this.http
      .post<AuthTokensDto>('/api/v1/auth/refresh', {}, { withCredentials: true })
      .pipe(tap((r) => this._accessToken.set(r.accessToken)),
            finalize(() => { this.refrescoEnCurso = null; }),
            shareReplay({ bufferSize: 1, refCount: true }));
    return this.refrescoEnCurso;
  }
}

// ── FRONTEND: guard de navegación. Ergonomía, NO seguridad. ──
export const authGuard: CanActivateFn = (_ruta, estado) => {
  const auth = inject(AuthService);
  const router = inject(Router);
  if (auth.autenticado()) return true;
  // Conservamos el destino para volver tras el login.
  return router.createUrlTree(['/login'], { queryParams: { redirectTo: estado.url } });
};

// ── BACKEND: aquí está la seguridad de verdad. ──
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false,
      secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
      issuer: 'taskflow-api', audience: 'taskflow-web',
    });
  }
  // Lo que devuelve validate() acaba en req.user: nunca el usuario entero.
  validate(payload: { sub: string; email: string }): AuthUser {
    return { id: payload.sub, email: payload.email };
  }
}
El error más repetido Proteger una ruta solo con authGuard en Angular y olvidar el guard en el controlador de Nest. El guard de Angular se salta escribiendo curl. Registra JwtAuthGuard como APP_GUARD global y marca lo público con un decorador @Public(): así lo seguro es el valor por defecto y abrir un endpoint es un acto deliberado.

18.8 Paginación, filtrado y ordenación de extremo a extremo

libs/shared/src/lib/pagination.contract.ts
export interface Paginated<T> {
  items: T[];
  total: number;      // filas que cumplen el filtro, no las de la página
  page: number;       // 1-indexado, como lo entiende un humano
  pageSize: number; totalPages: number;
}

export const TASK_SORT_FIELDS = ['createdAt', 'dueDate', 'title', 'status'] as const;
export type TaskSortField = (typeof TASK_SORT_FIELDS)[number];

export interface TaskQuery {
  page?: number; pageSize?: number;
  sort?: TaskSortField; dir?: 'asc' | 'desc';
  status?: TaskStatus; projectId?: string; assigneeId?: string;
  q?: string;         // búsqueda por título
}
tasks.controller.tsINCORRECTO
@Get()
async list(@Query() q: Record<string, string>) {
  // 1. Todo llega como string: q.page es "2", no 2, y el driver recibe texto
  //    donde espera un número.
  // 2. orderBy con la cadena del usuario: puede pedir una columna inexistente
  //    (error 500) o una relación no cargada.
  // 3. Sin tope de pageSize: ?pageSize=100000 descarga la tabla entera.
  return this.em.find(Task, {}, {
    limit: q['pageSize'] as never,
    offset: ((q['page'] as never) - 1) * (q['pageSize'] as never),
    orderBy: { [q['sort']!]: q['dir'] },
  });
}
tasks.controller.ts · dto/list-tasks-query.dto.tsCORRECTO
@Get()
list(@Query() query: ListTasksQueryDto, @CurrentUser() u: AuthUser): Promise<Paginated<TaskDto>> {
  return this.listarTareas.ejecutar(query, u.id);   // el DTO ya validó todo
}

export class ListTasksQueryDto implements TaskQuery {
  @Type(() => Number) @IsInt() @Min(1) @IsOptional() page = 1;
  @Type(() => Number) @IsInt() @Min(1) @Max(100) @IsOptional()   // TOPE duro
  pageSize = 20;
  @IsIn(TASK_SORT_FIELDS as readonly string[]) @IsOptional()     // LISTA BLANCA
  sort: TaskSortField = 'createdAt';
  @IsIn(['asc', 'desc']) @IsOptional() dir: 'asc' | 'desc' = 'desc';
  @IsIn(TASK_STATUSES as readonly string[]) @IsOptional() status?: TaskStatus;
  @IsUUID() @IsOptional() projectId?: string;
  @IsString() @MaxLength(100) @IsOptional() q?: string;
}
apps/api/src/tasks/use-cases/listar-tareas.use-case.ts
async ejecutar(q: ListTasksQueryDto, actorId: string): Promise<Paginated<TaskDto>> {
  // 1 · FilterQuery<Task> hace que el compilador rechace un campo inexistente.
  const where: FilterQuery<Task> = {
    // Filtro de seguridad SIEMPRE presente (o un @Filter global, capítulo 17).
    project: { team: { members: { id: actorId } } },
    ...(q.status ? { status: q.status } : {}),
    ...(q.projectId ? { project: q.projectId } : {}),
    ...(q.q ? { title: { $ilike: `%${q.q}%` } } : {}),
  };
  // 2 · Orden de lista blanca + "id" como desempate: paginación estable.
  const options: FindOptions<Task, 'assignee' | 'tags'> = {
    populate: ['assignee', 'tags'],            // evita el N+1 al mapear
    orderBy: { [q.sort]: q.dir === 'asc' ? QueryOrder.ASC : QueryOrder.DESC, id: QueryOrder.ASC },
    limit: q.pageSize, offset: (q.page - 1) * q.pageSize,
  };
  // 3 · findAndCount emite el SELECT y un COUNT(*) con el MISMO where.
  const [tareas, total] = await this.em.findAndCount(Task, where, options);
  return {
    items: tareas.map(TaskMapper.aDto), total, page: q.page, pageSize: q.pageSize,
    totalPages: Math.max(1, Math.ceil(total / q.pageSize)),
  };
}
Orden inestable: el bug que se ve como «una fila repetida en dos páginas» Si ordenas por createdAt y hay empates, PostgreSQL no garantiza el orden entre iguales: con OFFSET, la misma fila puede salir en la página 1 y en la 2, y otra no salir nunca. Añade siempre un desempate único. Para listados muy grandes o con inserciones constantes, la paginación por cursor (keyset) es superior a OFFSET, que se degrada linealmente; lo tratamos en el capítulo 16.
apps/web/src/app/tasks/task-list.component.ts
@Component({
  selector: 'tf-task-list',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <label>Buscar <input type="search" [value]="texto()" (input)="alBuscar($event)" /></label>
    <table>
      <thead><tr>@for (c of camposOrdenables; track c) {
        <th><button type="button" (click)="ordenarPor(c)" [attr.aria-sort]="ariaSort(c)">{{ c }}</button></th>
      }</tr></thead>
      <tbody>@for (t of pagina()?.items ?? []; track t.id) {
        <tr><td>{{ t.title }}</td><td>{{ t.status }}</td></tr>
      } @empty {
        <tr><td colspan="4">No hay tareas que cumplan el filtro.</td></tr>
      }</tbody>
    </table>
    @if (recurso.isLoading()) { <p role="status">Cargando…</p> }
    @if (recurso.error()) { <p role="alert">No se pudo cargar la lista.</p> }
    <nav aria-label="Paginación">
      <button [disabled]="filtros().page === 1" (click)="irA(filtros().page - 1)">Anterior</button>
      <span>Página {{ filtros().page }} de {{ pagina()?.totalPages ?? 1 }}</span>
      <button [disabled]="esUltima()" (click)="irA(filtros().page + 1)">Siguiente</button>
    </nav>`,
})
export class TaskListComponent {
  private readonly api = inject(TasksApi);
  protected readonly camposOrdenables = TASK_SORT_FIELDS;
  /** UN solo objeto de filtros: cambiar cualquier campo dispara UNA recarga. */
  protected readonly filtros = signal<Required<Pick<TaskQuery, 'page' | 'sort' | 'dir'>> & TaskQuery>(
    { page: 1, pageSize: 20, sort: 'createdAt', dir: 'desc' });
  protected readonly texto = computed(() => this.filtros().q ?? '');
  // Al cambiar params() cancela la petición anterior y lanza la nueva.
  protected readonly recurso = rxResource({
    params: () => this.filtros(),
    stream: ({ params }) => this.api.listar(params),
  });
  protected readonly pagina = computed(() => this.recurso.value());
  protected readonly esUltima = computed(() => this.filtros().page >= (this.pagina()?.totalPages ?? 1));
  protected irA(page: number): void { this.filtros.update((f) => ({ ...f, page })); }
  protected ordenarPor(sort: TaskSortField): void {
    // Cambiar el orden o el texto SIEMPRE vuelve a la página 1.
    this.filtros.update((f) => ({
      ...f, sort, page: 1, dir: f.sort === sort && f.dir === 'desc' ? 'asc' : 'desc' }));
  }

  protected alBuscar(e: Event): void {
    const q = (e.target as HTMLInputElement).value.trim() || undefined;
    this.filtros.update((f) => ({ ...f, q, page: 1 }));
  }

  protected ariaSort(campo: TaskSortField): 'ascending' | 'descending' | 'none' {
    if (this.filtros().sort !== campo) return 'none';
    return this.filtros().dir === 'asc' ? 'ascending' : 'descending';
  }
}
Nota de versiones (importante)

Las API de recursos de Angular han evolucionado rápido; comprueba la de tu versión antes de copiar código de internet. En Angular 19, resource() y rxResource() reciben la entrada en request y el cargador la recibe como { request, abortSignal }. En Angular 20 ese parámetro pasó a llamarse params y rxResource usa stream en lugar de loader.

También existe httpResource(), que permite escribir httpResource<Paginated<TaskDto>>(() => ({ url, params })) sin pasar por un servicio, pero sigue marcado como experimental. Si prefieres terreno completamente estable, toObservable(filtros) + switchMap + toSignal hace exactamente lo mismo y no va a cambiar.

18.9 Formularios y validación coherente

La misma regla —«el título tiene entre 3 y 200 caracteres»— debe existir en tres sitios, porque cada uno protege de algo distinto:

  ┌────────────────────────────────────────────────────────────────────────┐
  │ 1 · INTERFAZ (Validators)  evita que el usuario pierda el tiempo.      │
  │     Se salta con F12 o curl: NO es una defensa.                        │
  ├────────────────────────────────────────────────────────────────────────┤
  │ 2 · API (class-validator)  defiende de clientes hostiles, antiguos o   │
  │     de otro equipo. Es LA defensa funcional: errores por campo.        │
  ├────────────────────────────────────────────────────────────────────────┤
  │ 3 · BASE DE DATOS (NOT NULL, CHECK, UNIQUE, longitud)  defiende de     │
  │     scripts, migraciones a mano y bugs tuyos. Si aquí no está, la      │
  │     regla no está garantizada.                                         │
  └────────────────────────────────────────────────────────────────────────┘
     Las tres leen las MISMAS constantes: TASK_TITLE_MIN · TASK_TITLE_MAX
las tres capas, una sola constante
// ── libs/shared ──
export const TASK_TITLE_MIN = 3;
export const TASK_TITLE_MAX = 200;

// ── apps/web · formulario reactivo ──
titulo: new FormControl('', { nonNullable: true, validators: [
  Validators.required, Validators.minLength(TASK_TITLE_MIN), Validators.maxLength(TASK_TITLE_MAX),
]}),

// ── apps/api · DTO ──
@IsString() @MinLength(TASK_TITLE_MIN) @MaxLength(TASK_TITLE_MAX) title!: string;

// ── apps/api · entidad, que genera la migración ──
@Property({ length: TASK_TITLE_MAX }) title!: string;

// ── migración: varchar(200) not null, más el mínimo como restricción explícita:
//    alter table "tasks" add constraint "tasks_title_min" check (char_length(title) >= 3);

18.9.1 Volcar los errores del servidor en los controles

Aunque el formulario valide, el servidor puede rechazar por reglas que el cliente no conoce (unicidad, permisos, estado). Ese error debe aterrizar en el campo concreto, no en un toast anónimo.

shared/forms/aplicar-errores-servidor.ts · task-form.component.ts
/** form.get() acepta notación de punto, así que 'assignee.id' funciona tal cual. */
export function aplicarErroresServidor(form: FormGroup, error: ApiError): void {
  let alguno = false;
  for (const detalle of error.details) {
    const control = form.get(detalle.field);
    if (!control) continue;
    // 'servidor' es una clave propia: no pisa los errores de los Validators.
    control.setErrors({ ...(control.errors ?? {}), servidor: detalle.message });
    control.markAsTouched();
    alguno = true;
  }
  // Lo que no corresponde a ningún campo se muestra a nivel de formulario.
  if (!alguno) form.setErrors({ ...(form.errors ?? {}), servidor: error.mensajeParaUsuario() });
}

protected async guardar(): Promise<void> {
  if (this.form.invalid) { this.form.markAllAsTouched(); return; }
  this.enviando.set(true);
  try {
    const creada = await this.api.crear(this.form.getRawValue());
    this.store.anadir(creada);
    void this.router.navigate(['/tasks', creada.id]);
  } catch (e) {
    const error = e as ApiError;
    if (error.code === 'VALIDATION_FAILED' || error.code === 'CONFLICT') aplicarErroresServidor(this.form, error);
    else this.errorGeneral.set(error.mensajeParaUsuario());
  } finally {
    this.enviando.set(false);      // el botón se rehabilita PASE LO QUE PASE
  }
}
plantilla · mensajes accesibles por campo
<label for="titulo">Título</label>
<input id="titulo" formControlName="titulo" [attr.aria-invalid]="esInvalido('titulo')"
       [attr.aria-describedby]="esInvalido('titulo') ? 'titulo-error' : null" />
@if (esInvalido('titulo')) {
  <p id="titulo-error" class="error" role="alert">
    @let errores = form.controls.titulo.errors;
    @if (errores?.['required']) { El título es obligatorio. }
    @else if (errores?.['minlength']) { Mínimo 3 caracteres. }
    @else if (errores?.['maxlength']) { Máximo 200 caracteres. }
    @else if (errores?.['servidor']) { {{ errores['servidor'] }} }
  </p>
}
La validación asíncrona de unicidad no sustituye a la restricción Un AsyncValidator que consulta «¿existe ya este email?» mejora la experiencia, pero entre la comprobación y el envío pueden pasar segundos y otro usuario registrarse. La unicidad la garantiza el índice UNIQUE, y el backend traduce UniqueConstraintViolationException a un 409 con details apuntando al campo. El validador asíncrono es el aviso; el índice es la garantía.

18.10 Subida de archivos de extremo a extremo

web/attachments/upload.service.ts · api/attachments/attachments.controller.ts
export type EstadoSubida =
  | { tipo: 'progreso'; porcentaje: number }
  | { tipo: 'hecho'; adjunto: AttachmentDto };

subir(taskId: string, archivo: File): Observable<EstadoSubida> {
  const datos = new FormData();
  datos.append('file', archivo, archivo.name);   // 'file' = nombre del FileInterceptor
  return this.http
    .post<AttachmentDto>(`/api/v1/tasks/${taskId}/attachments`, datos, {
      reportProgress: true,     // sin esto no llegan los eventos de progreso
      observe: 'events',        // recibimos el flujo completo, no solo el cuerpo
    })
    .pipe(
      map((ev): EstadoSubida | null => {
        // total puede ser undefined si no se informa de Content-Length.
        if (ev.type === HttpEventType.UploadProgress)
          return { tipo: 'progreso', porcentaje: ev.total ? Math.round(100 * ev.loaded / ev.total) : 0 };
        if (ev.type === HttpEventType.Response && ev.body) return { tipo: 'hecho', adjunto: ev.body };
        return null;
      }),
      filter((e): e is EstadoSubida => e !== null),
    );
}

// IMPORTANTE: NO pongas Content-Type a mano. El navegador debe generar
// 'multipart/form-data; boundary=----WebKitFormBoundary...' él solo. Si lo fijas
// tú, falta el boundary y el servidor no puede parsear nada.

// ── apps/api · attachments.controller.ts ──
const TIPOS_PERMITIDOS = ['image/png', 'image/jpeg', 'image/webp', 'application/pdf'];

@Post()
@UseInterceptors(FileInterceptor('file', {
  limits: { fileSize: 10 * 1024 * 1024, files: 1 },   // el límite REAL, en el servidor
  // OJO: mimetype lo declara el CLIENTE. Filtro barato de primera línea, no una
  // garantía: la comprobación seria es por número mágico (magic bytes).
  fileFilter: (_req, file, cb) => cb(null, TIPOS_PERMITIDOS.includes(file.mimetype)),
}))
async subirArchivo(
  @Param('taskId', ParseUUIDPipe) taskId: string,
  @UploadedFile() file: Express.Multer.File,
  @CurrentUser() usuario: AuthUser,
): Promise<AttachmentDto> {
  if (!file) throw new BadRequestException('Falta el archivo o el tipo no está permitido');
  return this.subir.ejecutar({ taskId, file, actorId: usuario.id });
}
AlmacenamientoA favorEn contraCuándo
Disco localTrivial de montar, sin costeNo sobrevive a un contenedor efímero, no escala a varias instancias, complica las copiasDesarrollo o un único servidor con volumen persistente
S3 o compatible (MinIO, R2, GCS)Durabilidad, escalado, CDN, ciclo de vida, versionadoDependencia externa y costePor defecto en producción
En la base de datos (bytea)Transaccional con el restoInfla las copias, satura la memoria, castiga las consultasSolo archivos diminutos y críticos (una firma, un sello)
Nunca guardes el nombre original como nombre de archivo Lo elige el usuario y puede contener ../, caracteres de control o colisionar. Guarda una clave generada (attachments/2026/07/{uuid}.pdf) en storageKey y conserva el nombre original solo como filename, para mostrarlo y para la cabecera Content-Disposition al descargar.
  SUBIDA POR LA API (simple, pero el archivo atraviesa tu servidor)
  navegador ──10 MB──► Nest ──10 MB──► S3
     └─ ocupa memoria del proceso, consume ancho de banda, alarga el timeout del
        reverse proxy y limita la concurrencia

  SUBIDA CON URL PREFIRMADA (recomendada en producción)
  1) navegador ──► Nest   POST /attachments/presign { filename, mimeType, size }
                          valida tipo, tamaño y PERMISOS; genera storageKey y firma
                          una URL PUT válida 5 minutos
  2) navegador ◄── Nest   { uploadUrl, storageKey }
  3) navegador ──10 MB──► S3 directamente (progreso nativo del navegador)
  4) navegador ──► Nest   POST /tasks/:id/attachments { storageKey, filename, size }
                          comprueba con HeadObject que el objeto EXISTE y que tamaño
                          y tipo coinciden; crea la entidad Attachment

  El paso 4 es OBLIGATORIO: sin verificación, un cliente puede declarar un archivo
  que nunca subió, o subir 10 GB si no acotaste la firma.
apps/api/src/attachments/descargar.controller.ts
@Get(':id/download')
@UseGuards(JwtAuthGuard)
async descargar(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() usuario: AuthUser,
                @Res({ passthrough: true }) res: Response): Promise<void> {
  // 1 · La autorización se comprueba AQUÍ, no en el bucket. Un bucket público con
  //     nombres "difíciles de adivinar" no es control de acceso.
  const adjunto = await this.em.findOneOrFail(Attachment, {
    id, task: { project: { team: { members: { id: usuario.id } } } } });
  // 2 · Redirección a una URL firmada de corta duración: el binario sale de S3,
  //     no de nuestro proceso, pero el permiso lo hemos decidido nosotros.
  const url = await this.storage.urlDescargaFirmada(adjunto.storageKey, {
    expiraEnSegundos: 60, nombreDescarga: adjunto.filename });
  res.redirect(302, url);
}

18.11 Tiempo real de extremo a extremo

Cuando Ana marca una tarea como completada, Bruno debería verlo sin recargar. El reto no es abrir el socket: es reconciliar el evento que llega con el estado local sin pisar cambios más recientes.

api/realtime/tasks.gateway.ts · web/realtime/realtime.service.ts · task.store.ts
@WebSocketGateway({ namespace: '/rt', cors: { origin: ORIGENES, credentials: true } })
export class TasksGateway implements OnGatewayConnection {
  @WebSocketServer() private server!: Server;
  async handleConnection(socket: Socket): Promise<void> {
    // El handshake TAMBIÉN se autentica: un WebSocket no hereda los guards HTTP.
    const usuario = await this.auth.verificarToken(socket.handshake.auth?.['token']);
    if (!usuario) { socket.disconnect(true); return; }
    // Salas por proyecto: cada cliente recibe solo lo que puede ver. Difundir a
    // todos y filtrar en el cliente sería una fuga de datos.
    for (const id of await this.proyectos.idsVisiblesPara(usuario.id)) await socket.join(`project:${id}`);
  }

  emitirCambio(dto: TaskDto): void {
    // El evento lleva el DTO COMPLETO, no solo el id: así el cliente no hace un GET
    // por cada notificación (que sería un N+1 provocado por el frontend).
    this.server.to(`project:${dto.projectId}`).emit('task.updated', dto);
  }
}

// ── apps/web · realtime.service.ts ──
readonly estado = signal<'conectado' | 'reconectando' | 'desincronizado'>('reconectando');

conectar(): void {
  this.socket = io('/rt', { auth: { token: this.auth.accessToken() } });
  this.socket.on('connect', () => {
    // Al (re)conectar SIEMPRE resincronizamos: mientras estábamos fuera pudimos
    // perder eventos, y no hay forma de saber cuáles.
    this.store.recargarTodo().then(() => this.estado.set('conectado'));
  });
  this.socket.on('disconnect', () => this.estado.set('reconectando'));
  // Si tras varios intentos no volvemos, avisamos en vez de enseñar datos viejos
  // como si fueran actuales.
  this.socket.io.on('reconnect_failed', () => this.estado.set('desincronizado'));
  this.socket.on('task.updated', (dto: TaskDto) => this.store.aplicarRemoto(dto));
}

// ── En el store: la regla de oro es comparar VERSIONES, porque los mensajes de
//    WebSocket pueden llegar desordenados o duplicados. ──
aplicarRemoto(remoto: TaskDto): void {
  // Si tenemos una petición en vuelo para esa tarea, nuestro cambio optimista es
  // más reciente: lo ignoramos y esperamos a nuestra propia respuesta.
  if (this._enVuelo().has(remoto.id)) return;
  this._tasks.update((lista) => {
    const i = lista.findIndex((t) => t.id === remoto.id);
    if (i === -1) return lista;                             // no está en la vista actual
    if (lista[i].version >= remoto.version) return lista;   // evento antiguo: descartar
    const copia = [...lista]; copia[i] = remoto; return copia;
  });
}
Cuándo NO necesitas WebSockets El tiempo real cuesta: conexiones persistentes, autenticación del handshake, escalado horizontal con adaptador Redis, reconexión y pruebas más difíciles. Antes de montarlo, pregúntate si te basta con refrescar al recuperar el foco: document.addEventListener('visibilitychange', …) más un sondeo suave cada 30–60 segundos cubre el 80 % de los casos («que no se me quede la pantalla vieja») con veinte líneas de código. Reserva el WebSocket para lo que de verdad es colaborativo y simultáneo: un tablero que dos personas mueven a la vez, un chat, indicadores de presencia.

18.12 Transacciones y casos de uso

Un caso de uso es un escenario completo desde el punto de vista del usuario: «crear un proyecto con sus miembros iniciales y una tarea de bienvenida». No es «insertar en la tabla projects». Esa distinción determina dónde va la transacción: debe coincidir exactamente con el caso de uso, porque el estado intermedio (un proyecto sin miembros) no es un estado válido del sistema.

UbicaciónCómoValoración
Servicio de aplicaciónem.transactional(async (em) => {…})Recomendada. El límite es explícito y visible; puedes tener varias transacciones cortas y controlas el nivel de aislamiento
Interceptor global por peticiónUn NestInterceptor que abre y cierra la transacciónCómodo pero peligroso: envuelve también los GET, la mantiene abierta mientras se serializa la respuesta y esconde el límite. Prolonga los bloqueos
@CreateRequestContext()Crea un contexto fuera de una petición HTTPImprescindible en tareas programadas, consumidores de colas y comandos CLI, donde no existe el middleware de contexto
crear-proyecto.use-case.tsINCORRECTO
async ejecutar(cmd: CrearProyectoCommand): Promise<ProjectDto> {
  return this.em.transactional(async (em) => {
    const proyecto = new Project(cmd.nombre, cmd.teamId);
    em.persist(proyecto);
    await em.flush();
    // ❶ HTTP DENTRO de la transacción: si el proveedor tarda 30 s, los bloqueos de
    //    fila siguen abiertos 30 s. Con 50 peticiones así, el pool se agota.
    await this.email.enviarInvitaciones(cmd.miembros);
    // ❷ Si el flush siguiente falla, el proyecto se deshace... pero los correos YA
    //    SE ENVIARON. No hay rollback para el mundo exterior.
    for (const id of cmd.miembros) em.persist(new Membership(proyecto, id));
    await em.flush();
    // ❸ Y si el COMMIT falla, hemos invitado a gente a un proyecto que no existe.
    return ProjectMapper.aDto(proyecto);
  });
}
crear-proyecto.use-case.tsCORRECTO
async ejecutar(cmd: CrearProyectoCommand): Promise<ProjectDto> {
  // 1 · TODO lo transaccional junto, y NADA de E/S externa dentro.
  const dto = await this.em.transactional(async (em) => {
    const equipo = await em.findOneOrFail(Team, { id: cmd.teamId });
    const proyecto = new Project(cmd.nombre, equipo);
    em.persist(proyecto);
    for (const usuarioId of cmd.miembros) {
      const usuario = await em.findOneOrFail(User, { id: usuarioId });
      em.persist(new Membership(proyecto, usuario, 'member'));
    }
    // La bienvenida es parte del mismo escenario: sin ella, estado a medias.
    const bienvenida = new Task();
    bienvenida.title = `Bienvenido a ${proyecto.name}`;
    bienvenida.project = ref(proyecto); em.persist(bienvenida);
    // Un único flush: el Unit of Work ordena los INSERT respetando las FK.
    await em.flush();
    return ProjectMapper.aDto(proyecto);
  });
  // 2 · Efectos externos DESPUÉS del commit: si fallan, el proyecto ya existe.
  await this.cola.encolar('enviar-invitaciones', { proyectoId: dto.id, miembros: cmd.miembros });
  return dto;
}
La regla en una frase Dentro de una transacción solo debe haber SQL. Correos, webhooks, pasarelas de pago, subidas a S3, invalidaciones de caché y difusiones por WebSocket van después del commit, idealmente a través de una cola que garantice el reintento. Si necesitas que el efecto externo sea atómico con la escritura, el patrón es outbox: escribe el mensaje en una tabla dentro de la misma transacción y que un proceso aparte lo publique.

18.13 Actualizaciones optimistas y concurrencia

La interfaz optimista es lo que hace que una aplicación se sienta instantánea, pero estás enseñando un futuro que puede no ocurrir. Y si dos personas editan a la vez, el clásico «el último que guarda gana» hace desaparecer trabajo ajeno sin que nadie se entere.

  SIN BLOQUEO OPTIMISTA (actualización perdida)      CON BLOQUEO OPTIMISTA
   Ana              Bruno                             Ana            Bruno
    │ GET → v7        │ GET → v7                       │ GET → v7      │ GET → v7
    ├────────►        ├────────►                       ├───────►       ├───────►
    │ PATCH title="A" │                                │ PATCH {v:7}   │
    ├────────►  OK    │                                ├───────► UPDATE … WHERE version=7
    │                 │ PATCH status="done"            │         → 1 fila, v pasa a 8
    │                 ├────────►  OK: escribe TODO     │  200 OK       │ PATCH {v:7}
    │                 │  el objeto que él cargó,       │               ├───────► UPDATE … WHERE version=7
    │                 │  con el title ANTIGUO          │               │  → 0 filas
    │                 │                                │               │  OptimisticLockError
    │  ✘ el cambio de Ana ha desaparecido              │               │◄── 409 VERSION_CONFLICT
    │    y NADIE lo sabe                               │               │  ✔ Bruno se entera y decide
task.store.tsINCORRECTO
async cambiarEstado(id: string, status: TaskStatus) {
  // ❶ Cambio optimista sin guardar el estado previo: no hay reversión posible.
  this.parchear(id, (t) => ({ ...t, status }));
  // ❷ Promesa flotante, sin await ni catch: si falla, la UI miente para siempre
  //    y el error sale como UnhandledPromiseRejection.
  this.api.cambiarEstado(id, { status, version: 0 });
  // ❸ version fija a 0 → el backend no detecta conflictos: vuelves al
  //    "último que guarda, gana".
}
task.store.tsCORRECTO
async cambiarEstado(id: string, status: TaskStatus): Promise<void> {
  const original = this._tasks();                    // ❶ instantánea
  const actual = original.find((t) => t.id === id);
  if (!actual) return;
  this.parchear(id, (t) => ({ ...t, status }));
  try {
    // ❸ la versión REAL que el usuario tenía en pantalla
    const ok = await this.api.cambiarEstado(id, { status, version: actual.version });
    this.parchear(id, () => ok);                     // reconciliación
  } catch (e) {
    this._tasks.set(original);                       // ❷ reversión exacta
    await this.resolverConflicto(e as ApiError, id, status);
  }
}
apps/web/src/app/tasks/task.store.ts · las tres formas de resolver un 409
private async resolverConflicto(error: ApiError, id: string, intento: TaskStatus): Promise<void> {
  if (error.code !== 'VERSION_CONFLICT') {
    this.toasts.error(error.mensajeParaUsuario(), { requestId: error.requestId });
    return;
  }
  const servidor = await this.api.obtener(id);   // la verdad actual
  // A · RECARGAR: siempre correcta, a veces molesta; descarta el intento del
  //     usuario. Vale para datos de solo lectura o cambios triviales.
  this.parchear(id, () => servidor);
  // B · FUSIONAR cuando no hay solape real (Ana tocó el título y Bruno el estado).
  //     Solo es seguro con campos verificablemente independientes.
  if (servidor.status !== intento) {
    await this.api.cambiarEstado(id, { status: intento, version: servidor.version })
      .then((ok) => this.parchear(id, () => ok))
      .catch(() => this.toasts.avisar('No se pudo aplicar tu cambio; revisa la tarea.'));
    return;
  }
  // C · AVISAR y que decida el usuario. Obligatoria si el conflicto afecta a texto
  //     tecleado: nunca tires a la basura lo que alguien ha escrito.
  this.toasts.conflicto({
    mensaje: `«${servidor.title}» fue modificada por otra persona.`,
    acciones: [
      { texto: 'Ver la versión actual', accion: () => this.parchear(id, () => servidor) },
      { texto: 'Aplicar mi cambio igualmente',
        accion: () => this.api.cambiarEstado(id, { status: intento, version: servidor.version }) },
    ],
  });
}
Optimista frente a pesimista El bloqueo optimista (columna version) no bloquea nada: detecta el choque al escribir. Es lo correcto en una API web, donde el usuario tiene el formulario abierto minutos. El bloqueo pesimista (SELECT … FOR UPDATE, LockMode.PESSIMISTIC_WRITE) reserva la fila y hace esperar a los demás; solo tiene sentido dentro de una transacción cortísima —descontar existencias, asignar un correlativo— y jamás mientras se espera a un humano.

18.14 CORS, proxy y entornos

En desarrollo, Angular sirve en localhost:4200 y Nest escucha en localhost:3000. Son orígenes distintos (el puerto forma parte del origen), así que el navegador aplica CORS. Puedes configurarlo… o hacer que el problema no exista.

apps/web/proxy.conf.json · angular.json
{
  "/api": { "target": "http://localhost:3000", "secure": false, "changeOrigin": true },
  "/rt":  { "target": "http://localhost:3000", "ws": true, "secure": false }
}

// En angular.json, dentro de la configuración de serve:
//   "options": { "proxyConfig": "apps/web/proxy.conf.json" }

Con esto el navegador solo ve localhost:4200: el servidor de desarrollo reenvía /api a Nest por detrás. No hay origen cruzado, ni preflight, ni cookies rechazadas, y desarrollo se parece a producción, donde lo normal es servir todo bajo el mismo dominio.

  TOPOLOGÍA A · MISMO ORIGEN CON REVERSE PROXY   ← recomendada
                    https://app.taskflow.example
                    ┌─────────▼─────────┐
                    │  Nginx / Traefik  │
                    └─────────┬─────────┘
                 /api/*  ─────┴─────  todo lo demás
              ┌─────▼─────┐         ┌──────▼──────┐
              │  NestJS   │         │  estáticos  │
              │  :3000    │         │  de Angular │
              └───────────┘         └─────────────┘
   · Sin CORS que configurar, SameSite=Strict funciona y no hay preflight

  TOPOLOGÍA B · DOMINIOS DISTINTOS
   https://app.taskflow.example  ──CORS──►  https://api.taskflow.example
   · Access-Control-Allow-Origin con el origen EXACTO; con cookies, credentials
     en los dos lados y SameSite=None; Secure
   · Preflight OPTIONS en toda petición con Authorization o Content-Type JSON
   · Justificada si la API la consumen varias aplicaciones o clientes móviles
main.tsINCORRECTO
app.enableCors({
  origin: '*',          // cualquier web del mundo puede llamar a tu API
  credentials: true,    // ...y además con las cookies del usuario
});
// Tan insegura que los navegadores la RECHAZAN: con credentials, la especificación
// prohíbe el origen '*'. Sale un error de CORS confuso y alguien lo "arregla"
// desactivando más seguridad. Y esta variante, igual de mala, sí funciona:
app.enableCors({ origin: (o, cb) => cb(null, true), credentials: true });
main.tsCORRECTO
const ORIGENES = config
  .getOrThrow<string>('CORS_ORIGINS')     // "https://app.taskflow.example,https://admin…"
  .split(',').map((o) => o.trim());

app.enableCors({
  origin: ORIGENES,                       // lista blanca explícita y exacta
  credentials: true,                      // solo si usas cookies
  methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'],
  exposedHeaders: ['X-Request-Id'],       // sin esto, el JS del cliente NO la lee
  maxAge: 86_400,                         // cachea el preflight 24 h: menos latencia
});
Dos detalles de CORS que cuestan horas

Las cabeceras de respuesta no son visibles por defecto. Aunque el servidor envíe X-Request-Id, el JavaScript leerá null si no está en exposedHeaders. Por eso el requestId viaja también en el cuerpo del error.

Un error de CORS se ve en el cliente como status: 0. El navegador no deja leer la respuesta, así que tu interceptor lo interpretará como «sin conexión». Si ves «Sin conexión» pero el servidor registra un 200, es CORS, no la red.

apps/web/src/environments/environment.prod.ts
export const environment = {
  production: true,
  apiUrl: '/api',                  // mismo origen: ruta relativa, sin dominio
  wsUrl: '/rt',
  sentryDsn: 'https://abc123@o0.ingest.sentry.io/0',   // público por diseño
  featureFlags: { tiempoReal: true },
};

// TODO ESTO ACABA EN EL BUNDLE QUE DESCARGA CUALQUIERA: el environment del frontend
// no es un secreto, es configuración pública. Cualquiera abre main-XYZ.js y lo lee.
//   SÍ: URLs, flags, claves PÚBLICAS (Stripe pk_, Maps con restricción de dominio)
//   NO: claves privadas, secretos de JWT, cadenas de conexión, tokens de admin
// Si algo debe ser secreto, la operación que lo usa vive en el backend.

18.15 Estructura del repositorio

CriterioMonorepoRepositorios separados
Contrato compartidoImport directo; refactor atómico en un commitHay que publicar un paquete npm y coordinar versiones
Cambio que toca los dos ladosUn PR, una revisión, CI verde o roja de golpeDos PR, orden de despliegue obligatorio y ventana de incompatibilidad
CIMás compleja al principio; con caché y grafo solo se reconstruye lo afectadoTrivialmente simple por repositorio
DespliegueRequiere disciplina para no desplegar todo por cualquier cambioIndependiente por naturaleza
VersionadoConjunto: una versión del sistemaIndependiente: cada pieza a su ritmo
RecomendaciónPor defecto si un mismo equipo mantiene frontend y backendCuando son equipos y ciclos de vida distintos, o la API es pública
taskflow/
├── apps/api/                         NestJS
│   ├── src/main.ts                   pipes, filtro, CORS, versionado, Swagger
│   ├── src/app.module.ts             módulos + middleware
│   ├── src/core/                     requestId, filtro de errores, logger
│   ├── src/auth/                     estrategia JWT, guards, decoradores
│   ├── src/tasks/
│   │   ├── dto/                      DTOs de ENTRADA con class-validator
│   │   ├── use-cases/                un archivo por caso de uso + transacción
│   │   ├── task.entity.ts            entidad MikroORM con comportamiento
│   │   ├── task.mapper.ts            entidad → TaskDto
│   │   └── tasks.controller.ts
│   ├── src/migrations/               generadas por MikroORM, versionadas
│   └── test/                         e2e con Supertest + base de datos real
├── apps/web/                         Angular
│   ├── src/app/core/                 interceptores, ApiError, AuthService
│   ├── src/app/shared/               componentes y utilidades de UI
│   ├── src/app/tasks/                componente + store + servicio de API
│   ├── src/app/app.config.ts         providers de la aplicación
│   ├── src/environments/             configuración PÚBLICA por entorno
│   └── proxy.conf.json
├── libs/shared/src/lib/*.contract.ts @taskflow/shared · SOLO TypeScript puro
├── docker-compose.yml                postgres + minio para desarrollo
├── package.json                      workspaces + scripts
└── tsconfig.base.json                paths hacia libs/*
package.json · workspaces de npm y scripts de desarrollo
{
  "name": "taskflow",
  "private": true,
  "workspaces": ["apps/*", "libs/*"],
  "scripts": {
    "dev": "concurrently -n db,api,web -c blue,magenta,green \"npm:dev:db\" \"npm:dev:api\" \"npm:dev:web\"",
    "dev:db": "docker compose up postgres minio",
    "dev:api": "npm run start:dev --workspace=@taskflow/api",
    "dev:web": "npm run start --workspace=@taskflow/web",
    "build": "npm run build --workspaces --if-present",
    "test": "npm run test --workspaces --if-present",
    "db:migrate": "npm run mikro-orm --workspace=@taskflow/api -- migration:up",
    "openapi": "npm run dump:openapi --workspace=@taskflow/api && npm run gen:client --workspace=@taskflow/web"
  }
}
Nx frente a workspaces de npm Los workspaces de npm resuelven lo básico: un node_modules, dependencias entre paquetes locales y scripts agregados. Es suficiente para dos aplicaciones y una librería. Nx añade grafo de dependencias, ejecución solo de lo afectado (nx affected), caché local y remota, generadores y —lo más valioso aquí— reglas de frontera entre proyectos. Empieza con workspaces y migra a Nx cuando la CI tarde de más o cuando necesites imponer las fronteras por herramienta y no por convención.

18.16 Lista de comprobación de una feature completa

«Ya funciona en mi máquina» no es «terminado». Estos son los veinte pasos que separan una demo de una funcionalidad entregable; úsalos como plantilla de la descripción de tus pull requests.

#PasoQué implicaSeñal de que está hecho
1Modelo de datosTablas, columnas, tipos, nulabilidad, relacionesUn esquema revisado por alguien más
2MigraciónGenerada, revisada a mano y reversibleup y down probados sobre una copia de producción
3Índices y restriccionesUNIQUE, CHECK, FK e índices para los filtros previstosEXPLAIN sin seq scan en las consultas clave
4EntidadPropiedades, relaciones, version, comportamiento de dominioLas invariantes se prueban sin base de datos
5Contrato en libs/sharedDTO de entrada, DTO de salida, constantes, códigos de errorLos dos lados compilan contra él
6DTO de entrada validadoclass-validator, lista blanca, topes numéricosTest que envía basura y espera 400 con details
7Caso de usoOrquestación y transacción del escenario completoTest de integración con base de datos real
8Mapeador a DTOFrontera explícita entidad → respuestaNingún campo interno en el JSON
9ControladorMétodo HTTP, ruta, códigos de estado, versión201 con Location al crear, 204 al borrar
10AutorizaciónGuard de autenticación y de propiedad o rolTest que accede con otro usuario y espera 403
11ErroresCada fallo previsible mapeado a su código de negocioEl filtro global cubre 409, 422 y 404 de este endpoint
12Tests de backendUnitarios del dominio, integración del caso de uso, e2e del endpointCamino feliz y al menos dos de error
13Documentación de la API@ApiProperty, ejemplos, respuestas de errorEl esquema genera un cliente sin any
14Cliente en AngularMétodo en el servicio de API tipado con el contratoCompila sin aserciones de tipo
15Estado en el frontendStore, actualización optimista y reconciliaciónLa interfaz refleja el servidor tras cualquier operación
16InterfazComponente, plantilla, diseño adaptableRevisado en móvil y en escritorio
17Estados de carga, vacío y errorLos cuatro estados, no solo el felizCargando, con datos, vacío con llamada a la acción, y error con reintento
18Accesibilidad e i18nEtiquetas, foco, aria-*, contraste, teclado; textos extraídosNavegable solo con teclado y sin textos incrustados
19ObservabilidadLogs con requestId, métricas de latencia y error, trazasSe puede responder «¿cuántas veces falló ayer?» sin desplegar
20Test e2e y documentaciónUn recorrido de usuario real (Playwright o Cypress) y una nota en el CHANGELOGPasa en CI contra el entorno de pruebas

18.17 Errores comunes y cómo solucionarlos

SíntomaCausa realSolución
Un campo llega undefined en producción y nadie tocó ese códigoTipos duplicados a mano que se desincronizaron del backendlibs/shared como única definición, o cliente generado desde OpenAPI verificado en CI (18.4)
La respuesta incluye passwordHash, deletedAt o una relación enteraSe devuelve la entidad de MikroORM directamente desde el controladorMapeador explícito a DTO y tipo de retorno Promise<XxxDto> en el handler (18.5)
El formulario deja enviar algo que el servidor rechaza, o al revésValidación duplicada con reglas divergentes en cada capaConstantes compartidas leídas por los Validators, el DTO y la migración (18.9)
No 'Access-Control-Allow-Origin' header is presentOrigen fuera de la lista blanca, o origin: '*' junto a credentials: trueProxy en desarrollo; lista blanca exacta desde variable de entorno en producción (18.14)
Un XSS de una dependencia roba las sesiones de todos los usuariosEl token en localStorage: cualquier script de la página lo leeAccess token en memoria y refresh en cookie HttpOnly + Secure + SameSite (18.7 y capítulo 12)
La interfaz muestra una tarea completada que en la base de datos sigue abiertaCambio optimista sin reversión, o interceptor que devuelve EMPTY y se traga el errorGuardar la instantánea previa, propagar siempre el error y reconciliar con la respuesta (18.3 y 18.13)
Se crea un proyecto sin miembros, o se invita a un proyecto que no existeLa transacción no abarca el caso de uso, o hay E/S externa dentroem.transactional() alrededor del escenario completo; efectos externos tras el commit, por cola (18.12)
La lista tarda 8 segundos y el log muestra 300 consultasN+1 provocado por el frontend: pide la lista y luego un detalle por filaUn endpoint que devuelve lo que la vista necesita, con populate explícito y findAndCount (18.8)
Una fila aparece en dos páginas y otra desapareceOrdenación no determinista con OFFSETAñadir un desempate único al orderBy; valorar paginación por cursor (18.8)

18.18 Buenas y malas prácticas

Haz esto

  • Una sola definición del contrato. libs/shared o generación desde OpenAPI verificada en CI; nunca dos interfaces «iguales».
  • DTO de entrada y de salida siempre separados. Son contratos distintos con motivos de cambio distintos.
  • Un formato de error único producido por un filtro global, incluidos los 500 y los de validación.
  • requestId de punta a punta: cabecera, cuerpo del error, log del servidor y mensaje al usuario.
  • La transacción coincide con el caso de uso y no contiene ni una llamada externa.
  • Optimismo con reversión. Guarda la instantánea antes de suponer y reconcilia con la respuesta.
  • Lista blanca en todo lo que venga del cliente: campos de orden, tamaño de página, tipos de archivo, orígenes CORS.
  • Los cuatro estados de cada vista: cargando, con datos, vacío y error con reintento.

Evita esto

  • Devolver entidades del ORM desde un controlador: convierte tu esquema en tu API pública y filtra campos.
  • Copiar interfaces del backend al frontend. La deriva no da error de compilación: se descubre en producción.
  • Confiar en la validación del cliente. El guard de Angular y los Validators son ergonomía, no seguridad.
  • Devolver EMPTY en el interceptor de errores. Deja la interfaz mintiendo y oculta el fallo.
  • Reflejar cualquier origen en CORS «para que funcione ya».
  • Correos, webhooks o pasarelas dentro de una transacción. Bloqueas filas esperando a un tercero y pierdes la atomicidad real.
  • Secretos en el environment del frontend. Todo lo que hay ahí es público.
  • Pedir datos por fila desde el frontend. Es un N+1 que ni siquiera aparece como tal en el log.

18.19 Preguntas frecuentes

¿Merece la pena un monorepo para una sola aplicación con su API?
Sí casi siempre, y el motivo es libs/shared. Poder cambiar un DTO en el backend y ver inmediatamente qué se rompe en el frontend, en el mismo commit y en la misma ejecución de CI, elimina toda una categoría de errores. Empieza con workspaces de npm, que son diez líneas en un package.json; ya migrarás a Nx cuando la CI necesite ejecutar solo lo afectado o cuando quieras imponer las fronteras entre proyectos por herramienta.
¿Puedo usar las entidades de MikroORM como DTO y ahorrarme el mapeador?
Puedes, y funcionará unos meses. Después ocurrirá alguna de estas cuatro cosas: publicarás un campo interno sin querer al añadir una columna; la forma de la respuesta cambiará según el populate de cada endpoint; una colección inicializada meterá cuatro mil objetos en el JSON; o no podrás renombrar una columna sin romper a todos los clientes. El mapeador es aburrido a propósito: es una frontera explícita donde alguien decide qué sale. Ese «acordarse de tocarlo» es la funcionalidad, no el inconveniente.
¿Dónde valido: en Angular, en Nest o en la base de datos?
En los tres, porque protegen de cosas distintas. Angular evita que el usuario pierda el tiempo, y se salta con F12. Nest defiende de cualquier cliente, incluido uno hostil o uno tuyo desactualizado: es la validación funcional. La base de datos defiende de migraciones a mano, de scripts de mantenimiento y de tus propios bugs: es la última línea. Lo que sí debes evitar es duplicar la definición de la regla: las tres capas leen las mismas constantes de libs/shared.
¿Cómo evito que una actualización optimista deje la interfaz mintiendo?
Tres condiciones. Primera: guarda una instantánea del estado antes de aplicar el cambio y restáurala exactamente en el catch, no «hagas lo contrario», que corrompe si hubo otros cambios entretanto. Segunda: el interceptor de errores debe propagar siempre; si devuelve EMPTY, tu catch nunca se ejecuta. Y tercera: al recibir la respuesta correcta, sustituye tu suposición por el DTO del servidor, porque trae version y updatedAt que no podías adivinar.
¿Por qué recibo 409 si soy el único que está usando la aplicación?
Casi siempre porque enviaste una version obsoleta: la pestaña que tienes abierta cargó la tarea hace rato y desde entonces la modificaste desde otra pestaña, o un evento de tiempo real actualizó el servidor mientras tu store guardaba la versión antigua. También ocurre si lanzas dos peticiones seguidas sin esperar a la primera: ambas envían la misma versión y la segunda choca. La solución es la misma en los dos casos: la versión que envías debe salir siempre del último DTO recibido del servidor, y las operaciones sobre la misma entidad deben serializarse (por eso el store lleva el conjunto enVuelo).
¿Necesito WebSockets o me basta con refrescar?
Empieza sin ellos. Refrescar al recuperar el foco de la ventana más un sondeo suave cada 30–60 segundos resuelve el caso mayoritario, que es «que no se me quede la pantalla obsoleta», con veinte líneas y sin infraestructura. El WebSocket se justifica cuando la simultaneidad forma parte del producto: un tablero que varias personas mueven a la vez, un chat, indicadores de presencia. Y cuando lo montes, recuerda que trae consigo autenticación del handshake, salas por permisos, adaptador Redis para escalar y un estado «desincronizado» que hay que mostrar.
¿Cómo depuro un fallo que atraviesa las tres capas?
Siguiendo el requestId y estrechando el problema por mitades. Primero determina el lado: reproduce la petición con curl o desde el cliente de Swagger; si falla ahí, el frontend es inocente. En el backend, activa debug: true en la configuración de MikroORM para ver el SQL exacto y compáralo con lo que esperabas. Si curl funciona pero la aplicación no, compara la petición real en la pestaña Red del navegador: casi siempre falta una cabecera, sobra un campo que forbidNonWhitelisted rechaza, o hay un interceptor transformando el cuerpo.
¿El DTO de Nest y la interfaz del contrato compartido no son redundantes?
No, porque hacen cosas distintas y en momentos distintos. La interfaz de libs/shared es un tipo: se borra al compilar y sirve para que el compilador vigile los dos lados. La clase del DTO en Nest existe en tiempo de ejecución y lleva los decoradores de class-validator, que son los que comprueban de verdad lo que llega por el cable. La conexión entre ambas es la palabra clave implements: si el contrato cambia, la clase deja de compilar. Si prefieres una sola definición, Zod con un esquema compartido y un pipe de validación propio es una alternativa perfectamente válida.
¿Cómo evito que el frontend provoque un N+1 sin darse cuenta?
Diseñando los endpoints en función de las vistas y no de las tablas. Si la pantalla muestra tareas con su responsable y sus etiquetas, el endpoint debe devolver eso ya resuelto con populate, en lugar de obligar al cliente a pedir cada responsable por separado. Cuando el patrón se repite mucho, un DataLoader agrupa las peticiones por lote. Y sobre todo mide: registra el número de consultas por petición en desarrollo y falla el test si un endpoint supera un umbral. Un N+1 provocado desde el navegador no aparece en el log del backend como un problema, sino como trescientas peticiones legítimas.
¿Debo devolver 404 o 403 cuando el recurso existe pero no es tuyo?
Depende de si la simple existencia del recurso es información sensible. Un 403 confirma que el identificador existe, lo que permite enumerar recursos ajenos. En una aplicación interna de equipo, 403 es más honesto y más fácil de depurar. En un sistema multiinquilino donde los identificadores no deben poder sondearse, devuelve 404 para todo lo que no sea tuyo. Lo importante es elegir una política, escribirla y aplicarla en todos los endpoints: la incoherencia es lo que filtra información.

18.20 Ejercicios

Nivel 1 · básico

18.1 Crea libs/shared/src/lib/comment.contract.ts con CommentDto (id, body, autor con id y nombre, taskId, createdAt) y CreateCommentDto (solo body, entre 1 y 2000 caracteres, con las constantes exportadas). Después escribe en Nest la clase que implements el contrato con los decoradores de class-validator. Comprueba que, si cambias el tipo en el contrato, el backend deja de compilar.

18.2 Dibuja en papel el recorrido completo de DELETE /api/v1/tasks/:id, nombrando en orden las quince piezas que atraviesa. Marca en cuáles puede fallar y con qué código de error de nuestro contrato.

Nivel 2 · intermedio

18.3 Implementa el caso de uso completo «asignar una tarea a un usuario»: PATCH /tasks/:id/assignee con cuerpo { assigneeId: string | null; version: number }. Cubre las nueve capas: contrato, DTO validado, guard de propiedad, caso de uso con transacción y bloqueo optimista, mapeador, servicio de API en Angular, store con actualización optimista, componente y manejo del 409.

18.4 Escribe el exceptionFactory del ValidationPipe para que produzca un array de FieldError con el nombre de la restricción (minLength, isEmail) como code, en lugar del texto generado. Contempla los objetos anidados: el campo debe salir como 'assignee.id'.

18.5 Añade filtrado por varias etiquetas a la lista de tareas: query param tagIds repetible, validado como array de UUID con un máximo de diez, traducido a FilterQuery con $every (todas) frente a $some (cualquiera). Expón las dos semánticas con un parámetro tagMatch: 'all' | 'any' y compara el SQL generado en cada caso.

18.6 Sustituye la subida directa de adjuntos por el flujo de URL prefirmada de 18.10, incluida la verificación con HeadObject en el paso de confirmación. Escribe un test que intente confirmar un storageKey inexistente y compruebe que devuelve 422.

Nivel 3 · avanzado

18.7 Implementa el patrón outbox: una tabla outbox_messages donde el caso de uso escribe el evento dentro de la misma transacción, y un procesador que la vacía y publica en la cola. Demuestra con tests que, si el commit falla, no se publica nada, y que si el procesador se cae a medias el mensaje se reintenta sin duplicar el efecto (idempotencia por clave del mensaje).

18.8 Monta la generación de cliente desde OpenAPI en CI: un paso que arranca la API, vuelca openapi.json, regenera el cliente y falla si difiere de lo versionado. Añade después una comprobación de compatibilidad hacia atrás que detecte cambios rompedores (campo eliminado, tipo estrechado, parámetro obligatorio nuevo).

18.9 Implementa edición colaborativa de la descripción de una tarea con reconciliación real: varios usuarios editando, difusión por WebSocket, detección de conflicto por versión y fusión a tres bandas (base común, mi versión, versión del servidor), mostrando las diferencias cuando la fusión automática no sea segura.

Solución comentada · ejercicio 18.3 (asignar una tarea)

La clave está en que las nueve piezas usan el mismo nombre y la misma forma. Empezamos por el contrato, porque es lo que hace que el compilador vigile el resto.

// ── libs/shared ── null = desasignar; es un valor legítimo, no "falta el campo".
export interface UpdateTaskAssigneeDto { assigneeId: string | null; version: number }

// ── apps/api · dto/update-task-assignee.dto.ts ──
export class UpdateTaskAssigneeDto implements Contrato {
  @ValidateIf((o) => o.assigneeId !== null)    // permite null explícito...
  @IsUUID()                                    // ...pero si viene algo, debe ser UUID
  assigneeId!: string | null;
  @Type(() => Number) @IsInt() @Min(1) version!: number;
}

// ── apps/api · use-cases/asignar-tarea.use-case.ts ──
async ejecutar(cmd: AsignarTareaCommand): Promise<TaskDto> {
  const dto = await this.em.transactional(async (em) => {
    const task = await em.findOneOrFail(Task, { id: cmd.taskId }, { populate: ['assignee', 'tags'] });
    em.lock(task, LockMode.OPTIMISTIC, { lockVersion: cmd.version });
    if (cmd.assigneeId === null) {
      task.assignee = null;
    } else {
      // Regla de negocio: solo miembros del equipo. El ValidationPipe no puede
      // comprobarlo, porque necesita ir a la base de datos.
      const miembro = await em.findOne(User, {
        id: cmd.assigneeId, teams: { projects: { id: task.project.id } } });
      if (!miembro) throw new UsuarioNoEsMiembroError(cmd.assigneeId);   // → 422
      task.assignee = ref(miembro);
    }
    await em.flush();
    return TaskMapper.aDto(task);
  });
  this.eventos.publicarAsignacion(dto);   // fuera de la transacción
  return dto;
}

// ── apps/web · asignar() clona el esqueleto de cambiarEstado() en 18.3.4: instantánea,
//    parcheo optimista con el miembro del catálogo local, llamada con actual.version,
//    reconciliación con el DTO devuelto y reversión + resolverConflicto() en el catch.

Los tres errores típicos de este ejercicio. Primero, tratar null como «campo ausente»: con @IsOptional(), un assigneeId: null se salta la validación y además no distingues «desasignar» de «no tocar»; por eso se usa @ValidateIf. Segundo, intentar validar la pertenencia al equipo en el pipe: no puede, porque necesita consultar la base de datos; es una regla de negocio, va en el caso de uso y devuelve 422. Y tercero, ser optimista con datos que no tienes: si el store no conoce el nombre del nuevo responsable, pintar «Asignado a undefined» es peor que esperar medio segundo.

Solución comentada · ejercicio 18.4 (exceptionFactory con códigos estables)

Por defecto, class-validator entrega textos como "title must be longer than or equal to 3 characters". Traducir eso en el frontend es frágil: cambia con la versión de la librería y no se puede internacionalizar. Lo que queremos es el nombre de la restricción, que sí es estable, y está en ValidationError.constraints.

/** Aplana el árbol de errores: children contiene los de objetos y arrays anidados. */
function aplanar(errores: ValidationError[], prefijo = ''): FieldError[] {
  return errores.flatMap((e) => {
    // En arrays, e.property es el índice ("0"), así que la ruta queda "tags.0.id".
    const ruta = prefijo ? `${prefijo}.${e.property}` : e.property;
    const propios: FieldError[] = Object.entries(e.constraints ?? {})
      .map(([code, message]) => ({ field: ruta, code, message }));
    // Recursión: un DTO anidado tiene sus propios errores en children.
    return [...propios, ...(e.children?.length ? aplanar(e.children, ruta) : [])];
  });
}

export const validationPipe = new ValidationPipe({
  whitelist: true, forbidNonWhitelisted: true, transform: true,
  // La fábrica recibe el ÁRBOL de ValidationError, no los textos ya formateados.
  exceptionFactory: (errores: ValidationError[]) => new BadRequestException({
    message: 'Los datos enviados no son válidos',
    details: aplanar(errores),        // el filtro global lo copiará tal cual
  }),
});

Con esto, el filtro de 18.6.1 ya no tiene que adivinar el nombre del campo partiendo el texto por espacios: basta con leer details del cuerpo de la HttpException. El frontend recibe esto:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Los datos enviados no son válidos",
    "details": [
      { "field": "title", "code": "minLength", "message": "title must be longer than or equal to 3 characters" },
      { "field": "assignee.id", "code": "isUuid", "message": "assignee.id must be a UUID" }
    ],
    "requestId": "3f1c8a92-5e77-4a10-b3d5-9c0e2f8a1b44",
    "timestamp": "2026-07-31T15:04:05.123Z", "path": "/api/v1/tasks"
  }
}

El frontend traduce code con su catálogo de i18n y usa message solo como respaldo. Y como field viene en notación de punto, form.get('assignee.id') encuentra el control directamente, que es justo lo que necesita aplicarErroresServidor().

Detalle importante: para que children se rellene en objetos anidados hay que anotar la propiedad con @ValidateNested() y @Type(() => ClaseHija). Sin @Type, class-transformer deja un objeto plano en lugar de una instancia y no se valida nada dentro: es el fallo silencioso más habitual con DTOs anidados.

18.21 Resumen del capítulo

  • Es un solo sistema con un contrato en medio. La forma más barata de mantenerlo sano es que exista una sola vez: libs/shared en un monorepo, o un cliente generado desde OpenAPI y verificado en CI. Copiar interfaces produce una deriva que no da error de compilación y se descubre en producción.
  • El frontend nunca es una fuente de autoridad. Guards de ruta, Validators y botones deshabilitados son ergonomía. La validación, la autorización y las invariantes viven en el servidor, y las restricciones de integridad, en la base de datos.
  • Cada capa tiene un motivo de cambio distinto, y por eso existen la entidad, el DTO de entrada, el DTO de salida y a veces un modelo de vista. Colapsar entidad y dominio es sano; unificar entrada y salida, o publicar la entidad, no lo es nunca.
  • Un formato de error único producido por un filtro global que también traduce los errores del ORM, y consumido por un interceptor que normaliza y siempre propaga. Un interceptor que se traga el error deja la interfaz mintiendo.
  • El requestId cose el sistema entero: viaja en la cabecera, en el cuerpo del error, en el log y en el mensaje al usuario. Convierte «me ha dado un error» en una búsqueda de un segundo.
  • La transacción abarca el caso de uso completo y no contiene nada de E/S externa. Correos, webhooks y difusiones van después del commit, por cola o con el patrón outbox.
  • El optimismo necesita reversión y reconciliación. Guarda la instantánea, revierte al estado exacto si falla y sustituye tu suposición por el DTO del servidor cuando llegue.
  • La concurrencia es real aunque tengas pocos usuarios. Una columna version convierte una actualización perdida silenciosa en un 409 que el usuario puede resolver.
  • «Funciona» no es «terminado». La lista de veinte pasos de 18.16 —de la migración al test e2e, pasando por permisos, accesibilidad y observabilidad— es lo que separa una demo de una funcionalidad entregable.

18.22 Recursos adicionales

Siguiente paso Ya tienes el sistema cosido de punta a punta. El capítulo 19 baja un nivel más y se ocupa de lo que hay debajo del ORM: SQL, modelado de datos, normalización e índices, que es donde se decide si tu aplicación responde en 30 milisegundos o en 3 segundos.