El patrón Criteria en NestJS: lo que un cliente puede pedir es un archivo, no una firma
Cinco parámetros y un find() El ejemplo de todo el artículo es el catálogo de una biblioteca. Un libro guarda esto: // src/book/book.schema.ts @Schema({ timestamps: true }) export class Book { @Prop() title: string; @Prop({ type: Types.ObjectId, ref: "Author" }) author: Types.ObjectId; @Prop() publishedAt: Date; @Prop() copies: number; // ejemplares en la estantería @Prop() available: boolean; @Prop() acquisitionPrice: number; // lo que costó adquirirlo: interno, no se publica } El nombre del autor no está aquí: vive en la colección authors , al otro lado de esa referencia. Y la pantalla que consume el catálogo es una tabla con buscador, filtros por columna y paginación. El endpoint que la alimenta se escribe una vez y crece por acumulación. Empieza devolviendo una página con un orden fijo, y para cuando la tabla tiene todos sus filtros ha llegado a esto: // src/book/book.controller.ts @Controller("books") export class BookController { constructor( @InjectModel(Book.name) private readonly model: Model , ) {} @Get() async getAll( @Query("title") title?: string, @Query("available") available?: string, @Query("minCopies") minCopies?: string, @Query("sortBy") sortBy?: string, @Query("page") page?: string, ) { const filter: FilterQuery = {}; if (title) { filter.title = { $regex: title, $options: "i" }; } if (available) { filter.available = available === "true"; } if (minCopies) { filter.copies = { $gte: Number(minCopies) }; } const current = Number(page ?? 1); const [items, total] = await Promise.all([ this.model .find(filter) .sort({ [sortBy ?? "createdAt"]: -1 }) .skip((current - 1) * 20) .limit(20), this.model.countDocuments(filter), ]); return { items: items, total: total, page: current }; } } El método tiene decisiones correctas dentro: el total sale del mismo filtro que los elementos, así que la paginación no puede contradecirse, y las dos consultas viajan en paralelo. Un listado escrito así sostiene años de producción sin dar un incidente, y su comportamiento no es lo que este artículo se propone corregir. Lo que conviene medir es lo que queda escrito fuera del archivo. La firma del endpoint y la URL que hace falta para llamarlo son, juntas, un contrato: GET /books?title=dune&available=true&minCopies=3&sortBy=publishedAt&page=2 Ese contrato no está declarado en ningún sitio y ya está en producción: en cuanto alguien comparte esa URL en un ticket o la deja escrita en un script de importación, los cinco nombres de la query string tienen consumidores fuera del repositorio. Y el vocabulario en el que está redactado no es el del catálogo, es el de la colección: sortBy=publishedAt nombra un campo del documento tal como se llama en la base de datos, minCopies fija además un operador que no aparece en el nombre, y quien lee title=dune no puede saber si busca una coincidencia exacta o parcial, porque eso solo está escrito dentro del if . Adónde va esto Lo que este artículo construye es el patrón Criteria: un objeto que describe un listado -qué se filtra, cómo se ordena, qué página- y que viaja del cliente al repositorio traduciéndose dos veces, una en cada frontera. Conviene ver el resultado antes que el análisis, porque todo lo que viene después es la justificación de esta forma y no de otra. La misma tabla, contra el mismo endpoint, se pide así: GET /books ?filters[0][field]=title&filters[0][operator]=CONTAINS&filters[0][value][0]=dune &filters[1][field]=authorName&filters[1][operator]=EQUAL&filters[1][value][0]=Herbert &order[by]=publishedAt&order[type]=DESC &page=2&pageSize=20 La respuesta trae la página y lo que hace falta para dibujar el paginador: { "items": [], "totalItems": 143, "totalPages": 8, "pageSize": 20 } Y el controlador se queda sin un solo nombre de columna dentro: // src/book/infrastructure/nest/book.controller.ts @Get() async getAll( @Query() request: CriteriaRequest, ): Promise > { const useCase = new GetAllBooks(this.repository, new BookCriteriaRequestMapper()); return await useCase.execute({ request: request }); } Puestas las dos URL una al lado de la otra, cuatro diferencias se ven sin leer el servidor: - El operador está escrito. CONTAINS viaja en la petición, así que quien lee la URL sabe quedune busca una coincidencia parcial. En la versión anterior eso solo estaba dentro de unif . - El nombre no es el de la columna. authorName no existe en ningún documento -el autor está en otra colección- y aun así se filtra y se ordena por él como por cualquier otra columna. - La firma del endpoint no crece. Añadir el filtro por ejemplares, el rango de fechas o el décimo campo no cambia ni una línea del controlador: cambia una línea de un enum. - El formato es el mismo para todos los listados. El de autores y el de préstamos se piden igual, así que el cliente escribe un serializador y no uno por pantalla. Nada de eso sale gratis: llegar ahí son cuatro archivos por entidad y un traductor por motor de base de datos, y hay proyectos donde no compensa. El resto del artículo es por qué esa forma, qué cuesta y cuándo no vale la pena. Cinco puntos, y uno de otra naturaleza Los sitios donde el endpoint y quien lo llama quedan atados son cinco, y no son todos del mismo tipo. Los cuatro primeros se ven leyendo el archivo; el quinto solo se ve cuando aparece el segundo listado. 1. El operador vive en el cuerpo del método. title se resuelve con un $regex y minCopies con un $gte , pero ninguno de los dos nombres lo dice, así que el comportamiento del filtro se puede cambiar sin tocar la firma: convertir ese $regex en una coincidencia exacta no rompe ninguna compilación y la única señal es que las respuestas empiezan a traer menos filas. 2. El nombre del parámetro es el nombre del campo. sortBy=publishedAt funciona porque ese string se pasa tal cual a .sort() . Renombrar la propiedad en el esquema deja dos salidas: romper las URL que ya circulan, o mantener en el controlador una tabla de alias del nombre viejo al nuevo - que es el mapa de traducción que el patrón acaba formalizando, escrito a destiempo y solo para el campo que se movió. 3. La firma crece con los campos multiplicados por los operadores. minCopies cubre una de las comparaciones posibles sobre copies ; el máximo es otro parámetro y el rango exacto un tercero. El endpoint no acumula un parámetro por columna, acumula uno por cada pregunta que alguien quiso hacerle a una columna. 4. Lo que se puede filtrar no está escrito en ningún sitio: es el residuo de los if . Para saber qué acepta el endpoint hay que leer el método entero y quedarse con las ramas. Con el orden no hay ni ramas que leer, porque sortBy entra directo en .sort() : cualquier ruta del documento es un orden válido, incluidas las de los campos que el listado no devuelve. 5. El formato es privado de este endpoint. El siguiente listado -autores, préstamos, ejemplares- vuelve a decidirlo todo desde cero: si la página se pide con page o con offset , si el orden es sortBy más order o un único sort=-publishedAt , si un booleano viaja como true , como 1 o como la simple presencia del parámetro. Del lado del cliente, cada pantalla escribe su propio serializador y ninguno se parece al anterior lo bastante como para compartirlo. Los cuatro primeros son molestias de acoplamiento: viven dentro de un archivo, se corrigen editando ese archivo, y lo que cuesta corregirlas no depende de cuánto se haya tardado. El quinto es de otra naturaleza. No vive en ningún archivo, sino en el acuerdo entre quien escribe el endpoint y quien lo consume, y no crece con el número de campos: crece con el producto del número de listados por el número de clientes. Con un solo listado, cuatro filtros fijos y una única pantalla llamándolo, ninguno de los cinco tiene coste observable y el método de arriba es la respuesta proporcionada al problema. Se vuelven medibles cuando aparecen tres condiciones, que suelen aparecer juntas: el listado deja de ser uno, el cliente deja de ser uno, y los filtros dejan de ser fijos porque quien los compone es el usuario desde la cabecera de una tabla. Tres costes 1. La URL es una parte pública del esquema Los nombres que viajan en la query string son los nombres de los campos del documento, y una URL publicada no tiene versión ni deprecación: existe mientras alguien la conserve. El día que publishedAt pasa a firstPublishedAt , ni el compilador ni los tests dicen nada, y lo que se rompe son enlaces que ya circulan fuera del repositorio. El coste, sin embargo, no se paga al renombrar: se paga en que no se renombra, porque como no hay forma de saber quién llama con el nombre viejo, la migración se pospone y el nombre que ya no describe lo que guarda se queda. 2. El endpoint crece multiplicando, no sumando La firma acumula un parámetro por cada pregunta que se le puede hacer a un campo, y las preguntas útiles sobre una fecha o un número son varias; a eso se le multiplica el número de listados, porque cada uno repite la operación desde cero. El efecto está en la dirección del crecimiento: los parámetros entran pero no salen, porque retirar minCopies exige demostrar que nadie lo llama y esa demostración no se puede hacer contra un contrato que no está declarado. El método acaba siendo la suma de todas las pantallas que alguna vez lo llamaron, incluidas las que ya no existen. 3. Lo que se puede pedir no está escrito en ninguna parte sortBy llega como texto y entra directo en .sort() , así que la lista de campos por los que se puede ordenar no la decide el endpoint: la decide el esquema. Y ordenar por un campo es una forma de leerlo - con sortBy=acquisitionPrice y unas cuantas páginas se reconstruye el orden relativo de los precios de adquisición del catálogo entero, sin que la respuesta haya devuelto ni un solo precio. El efecto de segundo orden es dónde queda el control: esa superficie se amplía editando el esquema, no el controlador, así que quien añada mañana el margen del proveedor está ampliando lo que la API expone con un diff que no toca ninguno de los archivos donde alguien lo buscaría. Los tres
Comments
No comments yet. Start the discussion.