Los datos de un panel: agrupar, degradar, acotar, cachear
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”:
Promise.allSettled, noall. Es la diferencia entera.- Cada fallo se registra. Una clave en
nullsin traza es un hueco invisible: el cliente pinta “no hay datos” y nadie se entera de que hay una consulta rota. El log es lo que distingue “esta clínica no tiene perros” de “la consulta de especies está fallando”. nully[]significan cosas distintas, y el cliente tiene que pintarlas distinto:[]es “no hay datos todavía”,nulles “esto se ha roto”. Si la interfaz los trata igual, el degradado no sirve de nada.
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:
- Una respuesta a medias no se cachea. Si
settleAggregatedejó una clave ennull, guardar eso convierte un fallo de un segundo en quince minutos de gráfica vacía. - Un array vacío sí se cachea, porque es una respuesta buena. Tratarlo como “no hay nada, no guardo” dejaría a la clínica recién creada como la única que consulta la base de datos en cada visita.
- La caché es best-effort y no propaga. Si el almacén no responde se va a la base de datos y se pinta igual: aquí un error convertiría un panel en un 500 por culpa de una caché. Es lo contrario de lo que debe hacer un almacén de sesiones.
- Una vía de escape barata. Un
?fresh=1que salte la lectura —pero no la escritura— cuesta dos líneas y evita la conversación de “no me aparece lo que acabo de crear”.
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í:
- El censo va directo al read model: una fuente, ninguna regla. Cuando le llegó la caché, pasó a tener use case propio — cachear es lógica, y la cabecera del servicio ya avisaba de que eso lo saca de ahí.
- La serie es use case desde el principio, porque combina dos orígenes: cuatro series salen de la
base de datos y el volumen de notificaciones de otro servicio. Por eso es además la clave con más
probabilidad de venir
null: es la única que depende de que otro servicio esté en pie.
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:
- Que la tabla es caché y no fuente, reconstruible desde el origen, con el límite de hasta dónde llegue su retención.
- Cuándo se refrescó. La respuesta lleva el refresco más antiguo de las filas leídas, para que la pantalla pueda decir “datos de hace tres días” en vez de presentar como actual lo que lleva una semana sin tocarse. Un cron que se cae en silencio es peor que no tener cron.