ecosystem-wikicómo extender los templates sin romper sus convenciones
‹ todas las recipes

Los datos de un panel: agrupar, degradar, acotar, cachear

template-api
último cambio 2026-09-25

Un panel con once gráficas tiende a nacer con once endpoints, uno por gráfica. Funciona, y a los seis meses la pantalla hace once peticiones para pintarse, cada una con su ida y vuelta.

Agruparlas es fácil. Lo que hay que decidir son las cuatro cosas de alrededor.

Decisión 1: agrupar por parámetro, no por gráfica

El corte natural no es “las tres de arriba y las ocho de abajo”, sino qué necesita cada una para calcularse:

GET /stats/census              → los agregados sin parámetros: la foto de hoy
GET /stats/timeline?months=12  → las series por mes, todas con el mismo rango

Así de simple, y tiene una consecuencia que es el motivo real: fusionarlos en uno solo obligaría a recalcular el padrón entero cada vez que alguien mueve el selector de meses. El censo no depende del rango; la serie es lo único que cambia.

Dicho de otra forma: dos llamadas donde una pide lo que no cambia y la otra lo que sí. Si algún día aparece un tercer parámetro, aparece un tercer endpoint, no un cuarto argumento.

Decisión 2: una clave rota apaga su gráfica, no la pantalla

Agrupar tiene un coste que nadie menciona: con once endpoints, una consulta rota apagaba una gráfica; con Promise.all, apaga las once. Se agrupó para ahorrar peticiones, no para acoplar once gráficas a la más frágil.

export async function settleAggregate<T extends object>(
  logger: Logger,
  aggregate: string,
  parts: Pending<T>,
): Promise<Settled<T>> {
  const keys = Object.keys(parts) as (keyof T)[];
  const settled = await Promise.allSettled(keys.map((key) => parts[key]));
  // … la que se rechaza queda en null, y se registra
}

Tres detalles que hacen que esto no sea “tragarse errores”:

Y hay un test que lo protege mejor que cualquier comentario: sustituir allSettled por all tiene que romper la mitad de la suite. Si no rompe nada, el degradado no está probado.

Decisión 3: el rango es un DTO con tope, no un parseInt

Media docena de endpoints hacían @Query("months") months?: string y parseInt(months, 10). Con eso, ?months=9999 pasa, y el motor lo acepta encantado convirtiendo un panel en un escaneo largo.

export class MonthsQueryDto {
  @ApiPropertyOptional({ default: 12, minimum: 1, maximum: 24 })
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(24)
  readonly months: number = 12;
}

Un DTO en vez de seis parseInt sueltos cierra eso y además los deja iguales. Y uno solo para todos los módulos: nació dentro del módulo de estadísticas y acabó siendo idéntico al que hacía falta en el panel de plataforma — dos clases para la misma regla es justo lo que este patrón predica no hacer.

Cuando un consumidor necesita un tope más estricto, hereda:

export class RetentionMonthsQueryDto extends MonthsQueryDto {
  // Aquí el tope es 12 y no es arbitrario: más allá, el servicio de abajo
  // devolvería meses vacíos sin decir que están vacíos porque se purgaron.
  @Max(12) readonly months: number = 12;
}

class-validator acumula las reglas de la clase padre y manda la más estricta.

Decisión 4: el orden lo pone el cliente

El mismo agregado alimenta dos rankings con criterios distintos —volumen y porcentaje de fallo—, así que ordenar en el servidor obligaría a elegir uno, o a aceptar un ?sort= que es un ordenamiento mal disfrazado.

Son unas decenas de filas y el cliente ya las tiene en memoria. Ordena quien pinta.

Dónde entra la caché, y dónde no

Sólo en el agregado que no depende del rango. El padrón de una clínica no se mueve entre dos cargas de la pantalla:

stats:census:{tenantId}   TTL 900s

Y la serie se queda fuera a propósito: recibe months, el usuario mueve el selector esperando ver cambiar los datos, y cachear justo lo que acaba de pedir es la peor combinación de las dos cosas.

Cuatro reglas para que la caché no muerda:

Y la que no es una regla sino una advertencia: cachear va después de agrupar. Cachear los once endpoints de antes habría sido cachear la forma que estábamos quitando.

Lo que sí es un use case, y lo que no

¿Use case, o service con métodos? deja la puerta abierta a que una consulta sin lógica vaya directa al read model. El límite se ve bien aquí:

Cuando el agregado ya no cabe en una consulta

Si el dato vive en otro servicio y el panel lo pide por cada tenant, ninguna de las decisiones de arriba te salva: son N peticiones HTTP y la pantalla espera a la más lenta. Ahí el patrón cambia de familia — se precalcula de madrugada con una tarea programada y el endpoint lee una tabla local.

Dos cosas que hay que escribir cuando se llega a eso: