The Problem with Traditional API Filtering

Building robust APIs often requires handling complex client requests for filtering, sorting, and paginating data. Traditional methods, like passing numerous query parameters directly in the URL, quickly become unwieldy. Imagine a library catalogue: a client might want to find books by a specific author, published after a certain year, within a particular genre, sorted by title, and paginated to show ten results per page. Constructing such a URL with individual parameters can be verbose and error-prone.

This approach also presents challenges on the server side. Developers must meticulously parse and validate each parameter, leading to boilerplate code that can obscure the core business logic. Furthermore, as the number of filtering options grows, the API signature can become bloated and difficult to manage, making it hard for clients to understand what is available and for developers to maintain.

Introducing the Criteria Pattern

The Criteria pattern offers a more elegant solution by consolidating all filtering, sorting, and pagination logic into a single, structured object. Instead of a long string of query parameters, the client sends a single JSON object – a 'criteria' file – that encapsulates their entire request. This object can contain properties for filtering fields, sorting order, and pagination details.

Consider the library catalogue example again. With the Criteria pattern, a client request might look like this:


{
  filter: {
    author: 'J.R.R. Tolkien',
    publishedAfter: 1950,
    genre: 'Fantasy'
  },
  sort: {
    field: 'title',
    order: 'asc'
  },
  pagination: {
    page: 1,
    limit: 10
  }
}

This structured format is significantly more readable and maintainable for both the client and the server. It abstracts away the complexities of query construction and parsing, allowing developers to focus on what matters: retrieving the correct data.

Implementing the Criteria Pattern in NestJS

NestJS, with its modular architecture and strong typing, is well-suited for implementing the Criteria pattern. The core idea is to define a DTO (Data Transfer Object) that represents the criteria structure. This DTO will serve as the contract for incoming requests.

Let's define the DTO for our book catalogue:


// src/book/dto/book-criteria.dto.ts

import { IsOptional, IsString, IsInt, Min, Max, IsEnum } from 'class-validator';

enum SortOrder {
  ASC = 'asc',
  DESC = 'desc',
}

export class BookCriteriaDto {
  @IsOptional()
  filter?: {
    title?: string;
    author?: string;
    publishedAfter?: number;
    genre?: string;
  };

  @IsOptional()
  sort?: {
    field: string;
    order: SortOrder;
  };

  @IsOptional()
  pagination?: {
    page: number;
    limit: number;
  };
}

This DTO leverages NestJS's built-in validation capabilities via the class-validator library. By decorating the properties with validation decorators, NestJS automatically validates incoming request bodies against this structure, ensuring data integrity and reducing the need for manual validation logic in controllers.

NestJS controller receiving a structured criteria DTO for book data filtering

Connecting the DTO to the Service Layer

Once the criteria DTO is defined, it can be injected directly into the controller’s route handler. The controller’s responsibility is then to pass this validated DTO to the service layer, which will translate the criteria into a database query.

In the service layer, you would typically have a method that accepts the criteria DTO and uses it to build a query for your database. The specifics of this translation depend on the ORM or database driver you are using (e.g., TypeORM, Mongoose). For instance, with Mongoose, you might build a query object dynamically based on the properties present in the criteria DTO.


// src/book/book.service.ts

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Book, BookDocument } from './book.schema';
import { BookCriteriaDto } from './dto/book-criteria.dto';

@Injectable()
export class BookService {
  constructor(@InjectModel(Book.name) private bookModel: Model) {}

  async findBooks(criteria: BookCriteriaDto): Promise {
    const query: any = {};
    const sortOptions: any = {};
    let page = 0;
    let limit = 10;

    // Apply filters
    if (criteria.filter) {
      if (criteria.filter.title) {
        query.title = { $regex: criteria.filter.title, $options: 'i' };
      }
      if (criteria.filter.author) {
        query.author = criteria.filter.author;
      }
      if (criteria.filter.publishedAfter) {
        query.publicationYear = { $gt: criteria.filter.publishedAfter };
      }
      if (criteria.filter.genre) {
        query.genre = criteria.filter.genre;
      }
    }

    // Apply sorting
    if (criteria.sort) {
      sortOptions[criteria.sort.field] = criteria.sort.order === 'asc' ? 1 : -1;
    } else {
      // Default sort
      sortOptions.title = 1;
    }

    // Apply pagination
    if (criteria.pagination) {
      page = criteria.pagination.page - 1;
      limit = criteria.pagination.limit;
    }

    return this.bookModel
      .find(query)
      .sort(sortOptions)
      .skip(page * limit)
      .limit(limit)
      .exec();
  }
}

This service method demonstrates how to dynamically construct a Mongoose query. The criteria DTO is used to populate the query and sortOptions objects, and also to calculate the skip and limit values for pagination. This approach keeps the service logic clean and focused on data retrieval.

Benefits of the Criteria Pattern

Adopting the Criteria pattern brings several advantages:

  • Improved Readability and Maintainability: Both client-side request construction and server-side request parsing become much cleaner and easier to understand.
  • Reduced Boilerplate: NestJS's validation decorators handle much of the request validation, reducing the need for manual checks.
  • Enhanced Flexibility: Clients can easily combine various filtering, sorting, and pagination options without needing to know the underlying database query structure.
  • API Evolution: Adding new filtering or sorting capabilities becomes a matter of updating the DTO and the service layer, without breaking existing clients who don't use the new features.
  • Testability: The DTOs and service methods are easily testable in isolation.

The Criteria pattern is not just about filtering; it's a philosophy for designing flexible, robust, and maintainable APIs. By treating complex query requirements as a structured data object rather than a signature, developers can build more scalable and user-friendly applications. If you're managing APIs with evolving filtering needs, this pattern is a powerful tool to have in your arsenal.