DEV Community

The Criteria pattern in NestJS: what a client may ask for is a file, not a signature

The Criteria pattern in NestJS One single way to filter, sort and paginate any list. Five parameters and a find() The example running through this article is a library catalogue. A book stores this: // 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; // copies on the shelf @Prop() available: boolean; @Prop() acquisitionPrice: number; // what it cost us: internal, never published } The author's name is not here: it lives in the authors collection, on the other side of that reference. And the screen consuming the catalogue is a table with a search box, per-column filters and pagination. The endpoint feeding it is written once and grows by accretion. It starts returning a page with a fixed order, and by the time the table has all its filters it has become this: // 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 }; } } There are correct decisions inside that method: the total comes out of the same filter as the items, so the pagination cannot contradict itself, and both queries travel in parallel. A list written like this holds years of production without an incident, and its behaviour is not what this article sets out to fix. What is worth measuring is what ends up written outside the file. The endpoint's signature and the URL needed to call it are, together, a contract: GET /books?title=dune&available=true&minCopies=3&sortBy=publishedAt&page=2 That contract is declared nowhere and is already in production: the moment somebody shares that URL in a ticket or writes it into an import script, the five names in the query string have consumers outside the repository. And the vocabulary it is written in is not the catalogue's, it is the collection's: sortBy=publishedAt names a document field exactly as the database calls it, minCopies also pins down an operator that does not appear in the name, and whoever reads title=dune cannot tell whether it looks for an exact or a partial match, because that is only written inside the if . Where this is going What this article builds is the Criteria pattern: an object describing a list -what is filtered, how it is sorted, which page- travelling from the client to the repository and being translated twice, once at each border. It is worth seeing the result before the analysis, because everything that follows is the justification for this shape and not another. The same table, against the same endpoint, is requested like this: 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 The response carries the page and what is needed to draw the paginator: { "items": [], "totalItems": 143, "totalPages": 8, "pageSize": 20 } And the controller is left without a single column name inside it: // 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 }); } With both URLs side by side, four differences show up without reading the server: - The operator is written down. CONTAINS travels in the request, so whoever reads the URL knowsdune looks for a partial match. In the previous version that lived inside anif . - The name is not the column's. authorName exists in no document -the author is in another collection- and it is still filtered and sorted by like any other column. - The endpoint's signature does not grow. Adding the copies filter, the date range or the tenth field changes not one line of the controller: it changes one line of an enum. - The format is the same for every list. Authors and loans are requested the same way, so the client writes one serialiser instead of one per screen. None of that comes free: getting there is four files per entity plus one translator per database engine, and there are projects where it does not pay off. The rest of the article is why this shape, what it costs and when it is not worth it. Five coupling points, and one of a different kind The places where the endpoint and whoever calls it are tied together are five, and they are not all of the same kind. The first four are visible by reading the file; the fifth only becomes visible when the second list appears. 1. The operator lives in the body of the method. title is resolved with a $regex and minCopies with a $gte , but neither name says so, which means the filter's behaviour can change without touching the signature: turning that $regex into an exact match breaks no compilation, and the only signal is that responses start bringing back fewer rows. 2. The parameter name is the field name. sortBy=publishedAt works because that string is passed straight to .sort() . Renaming the property in the schema leaves two ways out: breaking URLs that are already circulating, or keeping an alias table from old name to new one inside the controller - which is the very translation map the pattern ends up formalising, written too late and only for the field that moved. 3. The signature grows with fields multiplied by operators. minCopies covers one of the possible comparisons over copies ; the maximum is another parameter and the exact range a third. The endpoint does not accumulate one parameter per column, it accumulates one per question somebody wanted to ask a column. 4. What can be filtered is written nowhere: it is the residue of the if s. To know what the endpoint accepts you have to read the whole method and keep the branches. With sorting there are not even branches to read, because sortBy goes straight into .sort() : any path in the document is a valid order, including those of the fields the list does not return. 5. The format is private to this endpoint. The next list -authors, loans, copies- decides everything again from scratch: whether the page is requested with page or offset , whether ordering is sortBy plus order or a single sort=-publishedAt , whether a boolean travels as true , as 1 or as the mere presence of the parameter. On the client side, every screen writes its own serialiser and none resembles the previous one enough to be shared. The first four are coupling nuisances: they live inside one file, they are fixed by editing that file, and what they cost to fix does not depend on how long you waited. The fifth is of a different kind. It lives in no file, but in the agreement between whoever writes the endpoint and whoever consumes it, and it does not grow with the number of fields: it grows with the number of lists multiplied by the number of clients. With a single list, four fixed filters and one screen calling it, none of the five has an observable cost and the method above is the proportionate answer to the problem. They become measurable when three conditions appear, and they tend to appear together: the list stops being one, the client stops being one, and the filters stop being fixed because the user composes them from a table header. Three costs 1. The URL is a public part of the schema The names travelling in the query string are the names of the document's fields, and a published URL has no version and no deprecation: it exists as long as somebody keeps it. The day publishedAt becomes firstPublishedAt , neither the compiler nor the tests say anything, and what breaks are links already circulating outside the repository. The cost, however, is not paid on renaming: it is paid in the fact that you do not rename, because since there is no way to know who calls with the old name, the migration gets postponed and the name that no longer describes what it stores stays. 2. The endpoint grows by multiplication, not by addition The signature accumulates one parameter per question that can be asked of a field, and the useful questions about a date or a number are several; multiply that by the number of lists, because each one repeats the exercise from scratch. The effect is in the direction of the growth: parameters come in but do not go out, because removing minCopies requires proving nobody calls it and that proof cannot be produced against a contract that is not declared anywhere. The method ends up being the sum of every screen that ever called it, including the ones that no longer exist. 3. What can be asked for is written nowhere sortBy arrives as text and goes straight into .sort() , so the list of fields you can sort by is not decided by the endpoint: it is decided by the schema. And sorting by a field is a way of reading it - with sortBy=acquisitionPrice and a few pages you reconstruct the relative order of the acquisition prices of the entire catalogue, without the response ever returning a single price. The second-order effect is where the control sits: that surface widens by editing the schema, not the controller, so whoever adds the supplier margin tomorrow is widening what the API exposes with a diff that touches none of the files anybody would look in. The three cost

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.