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.
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.
- Dibujar la arquitectura completa y decir, para cualquier responsabilidad (validar, autorizar, transaccionar, serializar), en qué capa vive y en cuáles no debe vivir.
- Seguir el recorrido de una petición por las quince piezas que atraviesa, y saber dónde poner el punto de ruptura cuando algo falla.
- Compartir tipos entre frontend y backend sin duplicarlos, con una librería común o generando el cliente desde OpenAPI, y saber qué no debe entrar nunca ahí.
- Diseñar un formato de error único, producirlo con un filtro global en Nest, consumirlo con un interceptor en Angular y trazarlo de punta a punta con un
requestId. - Implementar paginación, filtrado y ordenación tipados desde los query params hasta
FindOptions, con una tabla en Angular que los consume con señales. - Situar correctamente la transacción: que abarque el caso de uso completo, con actualizaciones optimistas y conflictos 409 resueltos en la interfaz.
- Configurar CORS, proxy y variables de entorno en desarrollo y producción sin abrir agujeros ni pelearte con el navegador.
- Cerrar una funcionalidad de verdad, con una lista de veinte pasos que va de la migración al test de extremo a extremo.
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:
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
| Capa | Su responsabilidad | Lo que NUNCA debe hacer |
|---|---|---|
| Componente Angular | Renderizar 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ñales | Ser 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 API | Un método por endpoint, tipado con los DTO compartidos. | Mostrar toasts, navegar, guardar estado o transformar el modelo. |
| Interceptor Angular | Transversales: token, X-Request-Id, normalización de errores, refresco. | Lógica de un endpoint concreto o if sobre URLs de negocio. |
| Middleware Nest | Lo 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. |
| Guard | Decidir sí/no sobre autenticación y autorización. | Modificar datos, cargar agregados enteros o ejecutar lógica de negocio. |
| Pipe de validación | Comprobar forma: tipos, obligatoriedad, rangos, formatos. | Consultar la base de datos para validar reglas (unicidad, existencia, permisos). |
| Controlador | Mapear 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 uso | Orquestar el escenario completo, delimitar la transacción, publicar eventos tras el commit. | Conocer HTTP (nada de Request, Response ni excepciones de transporte). |
| Entidad de dominio | Custodiar sus invariantes: «una tarea completada no vuelve a en curso». | Hacer consultas, conocer DTOs o depender del framework HTTP. |
| MikroORM | Persistir 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. |
| PostgreSQL | Integridad referencial, unicidad, atomicidad, aislamiento. La verdad definitiva. | Contener lógica de aplicación difusa en triggers que nadie sabe que existen. |
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.
// 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.
@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');
}
}
[(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.
@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.
@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
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}
| Aspecto | PUT | PATCH |
|---|---|---|
| Semántica | Sustituye el recurso completo por el cuerpo | Aplica una modificación parcial |
| Campos ausentes | «Bórralos» o «déjalos por defecto» | «No los toques» |
| Idempotencia | Idempotente por definición | No necesariamente (el nuestro sí lo es) |
| En nuestro caso | Obligaría a enviar título, descripción y etiquetas solo para marcar una casilla, con riesgo de pisar cambios ajenos | Enviamos 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
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;
}
}
// 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
@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;
}
}
@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;
}
}
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.
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;
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 falla | Qué ocurre técnicamente | Qué ve el usuario |
|---|---|---|
| Sin conexión | HttpErrorResponse con status: 0 | La casilla se revierte y aparece «Sin conexión. Comprueba tu red» |
| Timeout | Operador timeout(15000) en el interceptor | «El servidor tarda demasiado. Inténtalo de nuevo» |
| Token caducado | 401 → el interceptor refresca y reintenta una sola vez | Nada si el refresco funciona; si no, vuelve al login conservando la URL |
TaskOwnershipGuard | 403 FORBIDDEN | «No tienes permiso sobre esta tarea» y la casilla vuelve a su sitio |
ValidationPipe | 400 VALIDATION_FAILED con details | Mensaje genérico: indica un bug del cliente, no un error del usuario |
findOneOrFail | NotFoundError → 404 NOT_FOUND | «Esta tarea ya no existe» y se elimina de la lista |
| Invariante de dominio | TransicionInvalidaError → 422 | «No se puede pasar de completada a en curso; reábrela primero» |
em.lock | OptimisticLockError → 409 VERSION_CONFLICT | «Otra persona modificó esta tarea» y se recarga sola |
| Base de datos caída | DriverException → 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.
// 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.
// Ú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
| Estrategia | Cómo funciona | A favor | En contra | Cuándo |
|---|---|---|---|---|
| Librería compartida en monorepo | Un paquete TypeScript que importan las dos aplicaciones | Coste cero, sin generación, refactor seguro con el IDE, admite constantes y funciones puras | Exige monorepo; tienta a meter cosas que no deberían compartirse | Opción por defecto si controlas los dos lados |
| Cliente generado desde OpenAPI | Nest publica el esquema con @nestjs/swagger y un generador produce tipos y funciones | Sirve para clientes en otros lenguajes y equipos separados; el esquema es el contrato oficial | Paso de build extra; sin buenas anotaciones genera any | API pública, repositorios separados o consumidores heterogéneos |
| tRPC | Los tipos del servidor se infieren en el cliente, sin esquema intermedio | Seguridad de tipos extremo a extremo sin generación | Encaja mal aquí (ver el aviso) | Proyectos Next.js o Node+React con enrutado funcional |
| Copiar a mano | Duplicar la interfaz en el frontend | Ninguna que compense | Deriva silenciosa garantizada | Nunca, salvo consumir una API ajena (y entonces valida en runtime) |
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
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.
"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
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));
# 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
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 respuesta | No | Adelante: los clientes antiguos lo ignoran |
| Añadir un campo obligatorio a la petición | Sí | Hazlo opcional con valor por defecto, o nueva versión |
| Renombrar un campo | Sí | Publicar los dos, marcar el viejo como obsoleto y retirarlo tras un plazo anunciado |
| Ensanchar un tipo (nuevo valor en la unión) | Sí, sutilmente | El switch del cliente deja de ser exhaustivo: prevé una rama por defecto desde el principio |
| Eliminar un campo o endpoint | Sí | Obsolescencia anunciada, métricas de uso y retirada en una versión mayor |
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
| Forma | Quién manda en ella | Por qué cambia |
|---|---|---|
| Entidad ORM | El esquema de la base de datos | Migraciones, índices, normalización, rendimiento |
| Modelo de dominio | Las reglas de negocio | Cambia una política: «las tareas vencidas no se pueden completar» |
| DTO de entrada | El contrato público de escritura | Cambia lo que un cliente puede pedir; es la superficie de ataque |
| DTO de salida | El contrato público de lectura | Cambia lo que exponemos; una columna interna nueva no debe filtrarse |
| Modelo de vista | La interfaz concreta | Cambia el diseño, el idioma o el formato de fecha |
18.5.1 Cuándo colapsar capas sin pecar de sobreingeniería
- Entidad y modelo de dominio: colápsalas casi siempre. MikroORM es un Data Mapper y sus entidades son clases normales que admiten métodos. Meter el comportamiento dentro (como
task.cambiarEstado()) es el modelo de dominio rico y no cuesta nada. Separarlas solo compensa si el modelo de persistencia y el conceptual difieren mucho (event sourcing, esquema heredado intratable). - Modelo de vista: créalo solo cuando la plantilla lo pida. Si el
TaskDtose pinta tal cual, no inventes unTaskVm; extráelo cuando la vista necesite cincocomputedencadenados o formateo pesado. - DTO de entrada y de salida: no los unifiques nunca. Es la separación más rentable. Compartir la clase es como se cuelan los mass assignment: el cliente envía
{"role":"admin"}y tuem.assign()lo aplica. - DTO de salida y entidad: no los unifiques nunca. Ver el par siguiente.
@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.
@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
| Enfoque | Ventajas | Inconvenientes | Veredicto |
|---|---|---|---|
Función manual (TaskMapper.aDto) | Explícito, tipado al 100 %, sin magia, fácil de testear, coste nulo | Verboso; hay que acordarse de tocarlo al añadir campos | Recomendado. El «hay que acordarse» es la funcionalidad: obliga a decidir |
ClassSerializerInterceptor con @Exclude/@Expose | Integrado en Nest, poco código, útil para omitir campos sensibles | Lista negra: lo que olvides excluir se publica. Se rompe con relaciones perezosas | Red de seguridad adicional, nunca la única frontera |
| AutoMapper y similares | Menos repetición con decenas de mapeos casi idénticos | Configuración implícita, errores en runtime, tipado más débil | Solo 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.
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
@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
}
}
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
/** 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');
}
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;
}),
);
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ón | HTTP | Código | Quién decide | Qué ve el usuario |
|---|---|---|---|---|
| Red caída, DNS o CORS bloqueado | 0 | NETWORK | Interceptor | Toast persistente con botón «Reintentar» |
| Sin respuesta en 15 s | — | TIMEOUT | Interceptor | Toast; la operación se revierte |
| Token caducado | 401 | UNAUTHENTICATED | Interceptor | Nada si el refresco funciona; si no, login |
| Sin permiso | 403 | FORBIDDEN | Llamante | Mensaje en contexto; el botón se deshabilita |
| Recurso borrado | 404 | NOT_FOUND | Llamante | Se quita de la lista con aviso |
| Otro usuario editó antes | 409 | VERSION_CONFLICT | Llamante | Aviso y recarga o fusión (18.13) |
| Regla de negocio incumplida | 422 | UNPROCESSABLE | Llamante | El mensaje del servidor, tal cual |
| Excepción no prevista | 500 | INTERNAL | Interceptor | «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 │
@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 };
}
}
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
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
}
@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'] },
});
}
@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;
}
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)),
};
}
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.
@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';
}
}
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
// ── 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.
/** 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
}
}
<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>
}
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
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 });
}
| Almacenamiento | A favor | En contra | Cuándo |
|---|---|---|---|
| Disco local | Trivial de montar, sin coste | No sobrevive a un contenedor efímero, no escala a varias instancias, complica las copias | Desarrollo o un único servidor con volumen persistente |
| S3 o compatible (MinIO, R2, GCS) | Durabilidad, escalado, CDN, ciclo de vida, versionado | Dependencia externa y coste | Por defecto en producción |
En la base de datos (bytea) | Transaccional con el resto | Infla las copias, satura la memoria, castiga las consultas | Solo archivos diminutos y críticos (una firma, un sello) |
../, 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.
@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.
@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;
});
}
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ón | Cómo | Valoración |
|---|---|---|
| Servicio de aplicación | em.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ón | Un NestInterceptor que abre y cierra la transacción | Có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 HTTP | Imprescindible en tareas programadas, consumidores de colas y comandos CLI, donde no existe el middleware de contexto |
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);
});
}
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;
}
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
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".
}
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);
}
}
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 }) },
],
});
}
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.
{
"/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
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 });
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
});
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.
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
| Criterio | Monorepo | Repositorios separados |
|---|---|---|
| Contrato compartido | Import directo; refactor atómico en un commit | Hay que publicar un paquete npm y coordinar versiones |
| Cambio que toca los dos lados | Un PR, una revisión, CI verde o roja de golpe | Dos PR, orden de despliegue obligatorio y ventana de incompatibilidad |
| CI | Más compleja al principio; con caché y grafo solo se reconstruye lo afectado | Trivialmente simple por repositorio |
| Despliegue | Requiere disciplina para no desplegar todo por cualquier cambio | Independiente por naturaleza |
| Versionado | Conjunto: una versión del sistema | Independiente: cada pieza a su ritmo |
| Recomendación | Por defecto si un mismo equipo mantiene frontend y backend | Cuando 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/*
{
"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"
}
}
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.
| # | Paso | Qué implica | Señal de que está hecho |
|---|---|---|---|
| 1 | Modelo de datos | Tablas, columnas, tipos, nulabilidad, relaciones | Un esquema revisado por alguien más |
| 2 | Migración | Generada, revisada a mano y reversible | up y down probados sobre una copia de producción |
| 3 | Índices y restricciones | UNIQUE, CHECK, FK e índices para los filtros previstos | EXPLAIN sin seq scan en las consultas clave |
| 4 | Entidad | Propiedades, relaciones, version, comportamiento de dominio | Las invariantes se prueban sin base de datos |
| 5 | Contrato en libs/shared | DTO de entrada, DTO de salida, constantes, códigos de error | Los dos lados compilan contra él |
| 6 | DTO de entrada validado | class-validator, lista blanca, topes numéricos | Test que envía basura y espera 400 con details |
| 7 | Caso de uso | Orquestación y transacción del escenario completo | Test de integración con base de datos real |
| 8 | Mapeador a DTO | Frontera explícita entidad → respuesta | Ningún campo interno en el JSON |
| 9 | Controlador | Método HTTP, ruta, códigos de estado, versión | 201 con Location al crear, 204 al borrar |
| 10 | Autorización | Guard de autenticación y de propiedad o rol | Test que accede con otro usuario y espera 403 |
| 11 | Errores | Cada fallo previsible mapeado a su código de negocio | El filtro global cubre 409, 422 y 404 de este endpoint |
| 12 | Tests de backend | Unitarios del dominio, integración del caso de uso, e2e del endpoint | Camino feliz y al menos dos de error |
| 13 | Documentación de la API | @ApiProperty, ejemplos, respuestas de error | El esquema genera un cliente sin any |
| 14 | Cliente en Angular | Método en el servicio de API tipado con el contrato | Compila sin aserciones de tipo |
| 15 | Estado en el frontend | Store, actualización optimista y reconciliación | La interfaz refleja el servidor tras cualquier operación |
| 16 | Interfaz | Componente, plantilla, diseño adaptable | Revisado en móvil y en escritorio |
| 17 | Estados de carga, vacío y error | Los cuatro estados, no solo el feliz | Cargando, con datos, vacío con llamada a la acción, y error con reintento |
| 18 | Accesibilidad e i18n | Etiquetas, foco, aria-*, contraste, teclado; textos extraídos | Navegable solo con teclado y sin textos incrustados |
| 19 | Observabilidad | Logs con requestId, métricas de latencia y error, trazas | Se puede responder «¿cuántas veces falló ayer?» sin desplegar |
| 20 | Test e2e y documentación | Un recorrido de usuario real (Playwright o Cypress) y una nota en el CHANGELOG | Pasa en CI contra el entorno de pruebas |
18.17 Errores comunes y cómo solucionarlos
| Síntoma | Causa real | Solución |
|---|---|---|
Un campo llega undefined en producción y nadie tocó ese código | Tipos duplicados a mano que se desincronizaron del backend | libs/shared como única definición, o cliente generado desde OpenAPI verificado en CI (18.4) |
La respuesta incluye passwordHash, deletedAt o una relación entera | Se devuelve la entidad de MikroORM directamente desde el controlador | Mapeador 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és | Validación duplicada con reglas divergentes en cada capa | Constantes compartidas leídas por los Validators, el DTO y la migración (18.9) |
No 'Access-Control-Allow-Origin' header is present | Origen fuera de la lista blanca, o origin: '*' junto a credentials: true | Proxy 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 usuarios | El token en localStorage: cualquier script de la página lo lee | Access 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 abierta | Cambio optimista sin reversión, o interceptor que devuelve EMPTY y se traga el error | Guardar 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 existe | La transacción no abarca el caso de uso, o hay E/S externa dentro | em.transactional() alrededor del escenario completo; efectos externos tras el commit, por cola (18.12) |
| La lista tarda 8 segundos y el log muestra 300 consultas | N+1 provocado por el frontend: pide la lista y luego un detalle por fila | Un 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 desaparece | Ordenación no determinista con OFFSET | Añ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/sharedo 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.
requestIdde 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
Validatorsson ergonomía, no seguridad. - Devolver
EMPTYen 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
environmentdel 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?
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?
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?
libs/shared.¿Cómo evito que una actualización optimista deje la interfaz mintiendo?
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?
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?
¿Cómo depuro un fallo que atraviesa las tres capas?
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?
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?
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?
18.20 Ejercicios
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.
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.
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/shareden 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,
Validatorsy 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
requestIdcose 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
versionconvierte 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
- Angular · Interceptores de HttpClient — referencia oficial de los interceptores funcionales, su orden de ejecución y la inyección dentro de ellos.
- NestJS · Filtros de excepción — cómo funciona
@Catch(), su orden respecto a los interceptores y el acceso alArgumentsHost. - NestJS · OpenAPI (Swagger) — generación del esquema, plugin de CLI y anotaciones necesarias para que el cliente generado sea útil.
- NestJS · Validación — opciones del
ValidationPipe,exceptionFactoryy validación de objetos anidados. - MikroORM · Unit of Work — qué ocurre exactamente dentro de
flush()y cómo se ordenan las operaciones. - MikroORM · Transacciones y bloqueo —
em.transactional(),LockMode, bloqueo optimista y pesimista. - MDN · CORS — explicación completa en español del preflight, las credenciales y las cabeceras expuestas.
- RFC 9457 · Problem Details for HTTP APIs — estándar para cuerpos de error; alternativa válida al contrato propio de 18.6.
- Nx · Por qué un monorepo — argumentos, fronteras entre proyectos y ejecución de lo afectado.
- @hey-api/openapi-ts — generador de clientes TypeScript a partir de un esquema OpenAPI.