Pagination Module Documentation
This documentation explains the features and usage of the Pagination Module located at src/common/pagination.
Overview
The Pagination module provides a comprehensive solution for handling paginated data throughout the application. It supports:
- Offset-based pagination: Traditional page number and limit approach
- Cursor-based pagination: Efficient traversal using cursor tokens
- Advanced filtering: Enum, equality, date range, and custom filters
- Field ordering: Validation and transformation of sort parameters
- Error handling: Consistent error responses with detailed context
The module uses a pipe-based architecture with factory functions for maximum flexibility and type safety.
Related Documents
Table of Contents
- Overview
- Related Documents
- Table of Contents
- Module
- Pagination Strategies
- Filtering System
- Ordering
- Usage Examples
- Integration with Doc Module
- Implementation Notes
Module
PaginationService
Core service that processes pagination operations without redundant validation (pipes already validated input).
Methods:
offset<TReturn>()
Executes offset-based pagination.
async offset<TReturn>(
repository: IPaginationRepository,
args: IPaginationQueryOffsetParams
): Promise<IPaginationOffsetReturn<TReturn>>Parameters:
repository: Repository instance implementing IPaginationRepositoryargs: Validated pagination parameters from pipe
Default Values:
orderBy:{ createdAt: 'desc' }- Sort by creation date descending
Returns:
{
type: 'offset',
count: number, // Total items
perPage: number, // Items per page
page: number, // Current page
totalPage: number, // Total pages
hasNext: boolean, // Has next page
hasPrevious: boolean, // Has previous page
nextPage?: number, // Next page number (if hasNext)
previousPage?: number, // Previous page number (if hasPrevious)
data: TReturn[] // Paginated items
}cursor<TReturn>()
Executes cursor-based pagination.
async cursor<TReturn>(
repository: IPaginationRepository,
args: IPaginationQueryCursorParams
): Promise<IPaginationCursorReturn<TReturn>>Parameters:
repository: Repository instanceargs: Validated pagination parameters from pipe
Default Values:
orderBy:{ createdAt: 'desc' }- Sort by creation date descendingcursorField:'id'- Field used for cursor positioning
Cursor Validation:
- Cursor contains: cursor value, orderBy, and where conditions
- If
orderByorwhereconditions change: throwsBadRequestException(400) - Client must request from beginning if conditions change
- Prevents stale cursor navigation
Returns:
{
type: 'cursor',
cursor?: string, // Encoded cursor for next page
perPage: number, // Items per page
hasNext: boolean, // Has next page
count?: number, // Total count (if includeCount: true)
data: TReturn[] // Paginated items
}Input Validation (Pipes)
Architecture:
Client Request
↓
Pipes (Validation & Transformation)
├─ Format validation (integer, ISO date, etc.)
├─ Range validation (min/max)
├─ Value validation (allowed enum values)
└─ Transformation (to service format)
↓
Service (Business Logic)
├─ Assumes valid input
├─ No redundant checks
└─ Processes dataKey Principle: Pipes validate ALL input. Service assumes valid input.
Decorators
Pagination Query Decorators
@PaginationOffsetQuery
Decorator for offset-based pagination with search and ordering.
Options:
defaultPerPage: Items per page (default: 20, max: 100)availableSearch: Array of searchable fieldsavailableOrderBy: Array of fields available for ordering
Default Behavior:
- If no
orderBy: sorts bycreatedAt: DESC - Page defaults to 1
- PerPage defaults to PaginationDefaultPerPage (20)
Usage:
@PaginationOffsetQuery({
availableSearch: ['name', 'email'],
availableOrderBy: ['createdAt', 'name', 'email']
})
pagination: IPaginationQueryOffsetParamsTransformed to:
{
limit: 20, // from perPage
skip: 0, // (page - 1) * perPage
orderBy: { ... }, // { createdAt: 'desc' } by default
where: { ... }, // filters combined here
select: { ... }, // fields to select
include: { ... } // relations to include
}@PaginationCursorQuery
Decorator for cursor-based pagination.
Options:
defaultPerPage: Items per page (default: 20, max: 100)cursorField: Field for cursor (default: ‘id’)availableSearch: Array of searchable fieldsavailableOrderBy: Array of fields available for ordering
Default Behavior:
- If no
orderBy: sorts bycreatedAt: DESC - Cursor is optional (undefined = first page)
- PerPage defaults to PaginationDefaultPerPage (20)
Usage:
@PaginationCursorQuery({
availableSearch: ['name', 'email'],
cursorField: '_id'
})
pagination: IPaginationQueryCursorParamsFilter Decorators
@PaginationQueryFilterInEnum<T>
Filters by comma-separated enum values using ‘in’ operator.
Factory Function:
PaginationQueryFilterInEnum<T>(
field: string,
defaultEnum: T[],
options?: { customField?: string }
)Parameters:
field: Query parameter namedefaultEnum: Array of valid enum valuesoptions.customField: Database field name (defaults to field)
Usage:
@PaginationQueryFilterInEnum(
'status',
[EnumUserStatus.ACTIVE, EnumUserStatus.INACTIVE]
)
status?: Record<string, IPaginationIn>Transforms:
- Query:
?status=ACTIVE,INACTIVE - To:
{ status: { in: ['ACTIVE', 'INACTIVE'] } }
Validation:
- Throws
BadRequestException(400) if value not in enum - Error code:
5021 (filterInvalidValue)
@PaginationQueryFilterNinEnum<T>
Filters by comma-separated enum values using ‘not in’ operator.
Factory Function:
PaginationQueryFilterNinEnum<T>(
field: string,
defaultEnum: T[],
options?: { customField?: string }
)Usage:
@PaginationQueryFilterNinEnum(
'status',
[EnumUserStatus.BANNED, EnumUserStatus.INACTIVE]
)
status?: Record<string, IPaginationNin>Transforms:
- Query:
?status=BANNED,INACTIVE - To:
{ status: { notIn: ['BANNED', 'INACTIVE'] } }
@PaginationQueryFilterEqualBoolean
Filters by boolean value (‘true’/‘false’).
Usage:
@PaginationQueryFilterEqualBoolean('isActive')
isActive?: Record<string, IPaginationEqual>Transforms:
- Query:
?isActive=true - To:
{ isActive: { equals: true } }
Validation:
- Accepts only ‘true’ or ‘false’
- Throws
BadRequestException(400) for invalid boolean
@PaginationQueryFilterEqualNumber
Filters by numeric value.
Usage:
@PaginationQueryFilterEqualNumber('age')
age?: Record<string, IPaginationEqual>Transforms:
- Query:
?age=25 - To:
{ age: { equals: 25 } }
Validation:
- Parses as float
- Throws
BadRequestException(400) for non-numeric value
@PaginationQueryFilterEqualString
Filters by string value.
Usage:
@PaginationQueryFilterEqualString('role')
role?: Record<string, IPaginationEqual>Transforms:
- Query:
?role=admin - To:
{ role: { equals: 'admin' } }
@PaginationQueryFilterNotEqual<T>
Filters by inequality (not equal).
Factory Function:
PaginationQueryFilterNotEqual<T>(
field: string,
options?: { customField?: string, isBoolean?: boolean, isNumber?: boolean }
)Usage:
@PaginationQueryFilterNotEqual('status')
status?: Record<string, IPaginationNotEqual>Transforms:
- Query:
?status=inactive - To:
{ status: { not: 'inactive' } }
Supports same type conversions as equality filters.
@PaginationQueryFilterDate
Filters by ISO date string with range operations.
Factory Function:
PaginationQueryFilterDate(
field: string,
options?: {
customField?: string,
type?: EnumPaginationFilterDateBetweenType,
dayOf?: DayOfOption
}
)Parameters:
field: Query parameter nameoptions.type:START: Greater than or equal (gte) - use for start dateEND: Less than or equal (lte) - use for end date- Undefined: Equals - exact date match
options.dayOf: Day adjustment option
Usage:
@PaginationQueryFilterDate('createdAt', {
type: EnumPaginationFilterDateBetweenType.START
})
startDate?: Record<string, IPaginationDate>
@PaginationQueryFilterDate('createdAt', {
type: EnumPaginationFilterDateBetweenType.END
})
endDate?: Record<string, IPaginationDate>Transforms:
- Query:
?startDate=2024-01-01 - To:
{ createdAt: { gte: new Date('2024-01-01T00:00:00Z') } }
Validation:
- Accepts ISO format (YYYY-MM-DD, ISO 8601 timestamps)
- Throws
BadRequestException(400) for invalid ISO date
Ordering Decorator
@PaginationOrder
Decorator for field ordering.
Factory Function:
PaginationOrderPipe(defaultAvailableOrder?: string[]): Type<PipeTransform>Parameters:
defaultAvailableOrder: Array of fields allowed for ordering
Default Behavior:
- If no
orderBy: sorts bycreatedAt: DESC - If
orderBynot in allowed fields: throwsBadRequestException(400)
Usage:
@PaginationOrder(['createdAt', 'name', 'email'])
order?: IPaginationOrderByQuery Parameters:
orderBy: Field name (must be in allowed list)orderDirection: ‘asc’ or ‘desc’
Transforms:
- Query:
?orderBy=name&orderDirection=asc - To:
{ name: 'asc' }
Validation:
- Field must be in allowed list
- Throws error code:
5020 (orderByNotAllowed)
Pagination Strategies
Offset-Based
Characteristics:
- Returns total count
- Slower with large offsets
- Predictable page numbers
- Affected by inserts/deletes during pagination
Constraints:
- Max page: 20
- Max perPage: 100
- Min page: 1
- Min perPage: 1
Response Example:
{
"type": "offset",
"count": 250,
"perPage": 20,
"page": 1,
"totalPage": 13,
"hasNext": true,
"hasPrevious": false,
"nextPage": 2,
"data": [...]
}Cursor-Based
How It Works:
- Cursor encodes: cursor value, orderBy, where conditions
- Cursor validates conditions match on each request
- If conditions change: throws error (client must restart)
- Prevents navigation with stale conditions
Characteristics:
- Cursor-based navigation (no page numbers)
- Consistent performance (indexed cursor field)
- Optional count (requests only if needed)
- Safe for real-time data changes
- MongoDB ObjectID timestamps prevent duplicates
Constraints:
- Max cursor length: 256 characters
- Cursor format: URL-safe base64 (A-Za-z0-9_-)
- Max perPage: 100
- Min perPage: 1
Response Example:
{
"type": "cursor",
"cursor": "eyJjdXJzb3I6IjEyMyIsIm9yZGVyQnkiOnsidGltZXN0YW1wIjoiZGVzIn0sIndoZXJlIjp7fX0=",
"perPage": 20,
"hasNext": true,
"data": [...]
}Filtering System
Filters combine using spread operator into the where clause:
return this.paginationService.offset(repository, {
...pagination,
where: {
...where,
...status, // Adds: { in: [...] }
...role, // Adds: { equals: '...' }
...country, // Adds: { not: '...' }
deletedAt: null
}
});Enum Filters
In (inclusion):
@PaginationQueryFilterInEnum('status', [ACTIVE, INACTIVE])
status?: Record<string, IPaginationIn>
// Query: ?status=ACTIVE,INACTIVE
// Database: WHERE status IN ('ACTIVE', 'INACTIVE')Nin (exclusion):
@PaginationQueryFilterNinEnum('status', [BANNED, DELETED])
status?: Record<string, IPaginationNin>
// Query: ?status=BANNED,DELETED
// Database: WHERE status NOT IN ('BANNED', 'DELETED')Equality Filters
Boolean:
@PaginationQueryFilterEqualBoolean('isActive')
isActive?: Record<string, IPaginationEqual>
// Query: ?isActive=true
// Database: WHERE isActive = trueNumber:
@PaginationQueryFilterEqualNumber('age')
age?: Record<string, IPaginationEqual>
// Query: ?age=25
// Database: WHERE age = 25String:
@PaginationQueryFilterEqualString('role')
role?: Record<string, IPaginationEqual>
// Query: ?role=admin
// Database: WHERE role = 'admin'Not Equal:
@PaginationQueryFilterNotEqual('country')
country?: Record<string, IPaginationNotEqual>
// Query: ?country=US
// Database: WHERE country != 'US'Date Filters
Date Range:
@PaginationQueryFilterDate('createdAt', {
type: EnumPaginationFilterDateBetweenType.START
})
startDate?: Record<string, IPaginationDate>
@PaginationQueryFilterDate('createdAt', {
type: EnumPaginationFilterDateBetweenType.END
})
endDate?: Record<string, IPaginationDate>
// Query: ?startDate=2024-01-01&endDate=2024-12-31
// Database: WHERE createdAt >= '2024-01-01' AND createdAt <= '2024-12-31'Ordering
Default Behavior:
- Field:
createdAt - Direction:
desc(descending)
Query Parameters:
?orderBy=name&orderDirection=ascField Whitelist: Must be specified in decorator to prevent SQL injection:
@PaginationOrder(['createdAt', 'name', 'email'])Usage Examples
Basic Offset Pagination
Controller:
@Get('/users')
@ResponsePaging('user.list')
async listUsers(
@PaginationOffsetQuery({
availableSearch: ['name', 'email'],
availableOrderBy: ['createdAt', 'name']
})
pagination: IPaginationQueryOffsetParams
) {
return this.userService.getListOffset(pagination);
}Service:
async getListOffset(
pagination: IPaginationQueryOffsetParams
): Promise<IResponsePagingReturn<UserListResponseDto>> {
const { data, ...others } =
await this.userRepository.findWithPaginationOffset(pagination);
const users = this.userUtil.mapList(data);
return { data: users, ...others };
}Repository:
async findWithPaginationOffset(
pagination: IPaginationQueryOffsetParams
): Promise<IResponsePagingReturn<IUser>> {
return this.paginationService.offset<IUser>(
this.databaseService.user,
{
...pagination,
where: {
...pagination.where,
deletedAt: null
},
include: { role: true }
}
);
}API Request:
GET /users?page=1&perPage=20&search=john&orderBy=name&orderDirection=ascCursor Pagination
Controller:
@Get('/users')
@ResponsePaging('user.list')
async listUsers(
@PaginationCursorQuery({
availableSearch: ['name', 'email'],
cursorField: '_id'
})
pagination: IPaginationQueryCursorParams
) {
return this.userService.getListCursor(pagination);
}Service:
async getListCursor(
pagination: IPaginationQueryCursorParams
): Promise<IPaginationCursorReturn<UserListResponseDto>> {
const { data, ...others } =
await this.userRepository.findWithPaginationCursor(pagination);
const users = this.userUtil.mapList(data);
return { data: users, ...others };
}Repository:
async findWithPaginationCursor(
pagination: IPaginationQueryCursorParams
): Promise<IPaginationCursorReturn<IUser>> {
return this.paginationService.cursor<IUser>(
this.databaseService.user,
{
...pagination,
where: {
...pagination.where,
deletedAt: null
},
include: { role: true }
}
);
}API Requests:
# First page
GET /users?perPage=20&orderBy=name&orderDirection=asc
# Next page
GET /users?cursor=eyJjdXJzb3I6IjEyMyIsIm9yZGVyQnkiOnsibmFtZSI6ImFzYyJ9fQ==&perPage=20With Filters
Controller:
@Get('/users')
@ResponsePaging('user.list')
async listUsers(
@PaginationOffsetQuery({
availableSearch: ['name', 'email'],
availableOrderBy: ['createdAt', 'name']
})
pagination: IPaginationQueryOffsetParams,
@PaginationQueryFilterInEnum(
'status',
[EnumUserStatus.ACTIVE, EnumUserStatus.INACTIVE]
)
status?: Record<string, IPaginationIn>,
@PaginationQueryFilterEqualString('role')
role?: Record<string, IPaginationEqual>,
@PaginationQueryFilterEqualBoolean('isActive')
isActive?: Record<string, IPaginationEqual>,
@PaginationQueryFilterDate('createdAt', {
type: EnumPaginationFilterDateBetweenType.START
})
startDate?: Record<string, IPaginationDate>
) {
return this.userService.getListOffset(
pagination,
status,
role,
isActive,
startDate
);
}Service:
async getListOffset(
pagination: IPaginationQueryOffsetParams,
status?: Record<string, IPaginationIn>,
role?: Record<string, IPaginationEqual>,
isActive?: Record<string, IPaginationEqual>,
startDate?: Record<string, IPaginationDate>
): Promise<IResponsePagingReturn<UserListResponseDto>> {
const { data, ...others } =
await this.userRepository.findWithPaginationOffset(
pagination,
status,
role,
isActive,
startDate
);
const users = this.userUtil.mapList(data);
return { data: users, ...others };
}Repository:
async findWithPaginationOffset(
{ where, ...pagination }: IPaginationQueryOffsetParams,
status?: Record<string, IPaginationIn>,
role?: Record<string, IPaginationEqual>,
isActive?: Record<string, IPaginationEqual>,
startDate?: Record<string, IPaginationDate>
): Promise<IResponsePagingReturn<IUser>> {
return this.paginationService.offset<IUser>(
this.databaseService.user,
{
...pagination,
where: {
...where,
...status, // Spreads { status: { in: [...] } }
...role, // Spreads { role: { equals: '...' } }
...isActive, // Spreads { isActive: { equals: true } }
...startDate, // Spreads { createdAt: { gte: Date } }
deletedAt: null
},
include: { role: true }
}
);
}API Request:
GET /users?page=1&perPage=20&status=ACTIVE,INACTIVE&role=admin&isActive=true&createdAt=2024-01-01Complete Example
Controller with all features:
@ApiTags('modules.admin.user')
@Controller({
version: '1',
path: '/user',
})
export class UserAdminController {
constructor(private readonly userService: UserService) {}
@Get('/list')
@ResponsePaging('user.list')
async list(
@PaginationOffsetQuery({
availableSearch: ['name', 'email'],
availableOrderBy: ['createdAt', 'email', 'name']
})
pagination: IPaginationQueryOffsetParams,
@PaginationQueryFilterInEnum(
'status',
[EnumUserStatus.ACTIVE, EnumUserStatus.INACTIVE]
)
status?: Record<string, IPaginationIn>,
@PaginationQueryFilterNinEnum(
'blockedStatus',
[EnumUserStatus.BANNED]
)
blockedStatus?: Record<string, IPaginationNin>,
@PaginationQueryFilterEqualBoolean('isActive')
isActive?: Record<string, IPaginationEqual>,
@PaginationQueryFilterEqualNumber('age')
age?: Record<string, IPaginationEqual>,
@PaginationQueryFilterEqualString('role')
role?: Record<string, IPaginationEqual>,
@PaginationQueryFilterNotEqual('country')
country?: Record<string, IPaginationNotEqual>,
@PaginationQueryFilterDate('createdAt', {
type: EnumPaginationFilterDateBetweenType.START
})
startDate?: Record<string, IPaginationDate>,
@PaginationQueryFilterDate('createdAt', {
type: EnumPaginationFilterDateBetweenType.END
})
endDate?: Record<string, IPaginationDate>
) {
return this.userService.getListOffset(
pagination,
status,
blockedStatus,
isActive,
age,
role,
country,
startDate,
endDate
);
}
}Integration with Doc Module
The Pagination module integrates with the Doc module for automatic API documentation.
Example:
@DocResponsePaging<UserListResponseDto>('user.list', {
dto: UserListResponseDto,
availableSearch: ['name', 'email'],
availableOrder: ['createdAt', 'name']
})
@Get('/list')
async list(
@PaginationOffsetQuery({
availableSearch: ['name', 'email'],
availableOrderBy: ['createdAt', 'name']
})
pagination: IPaginationQueryOffsetParams
) {
return this.userService.getListOffset(pagination);
}The @DocResponsePaging decorator automatically:
- Documents paginated response structure
- Adds standard pagination query parameters
- Documents search parameter when provided
- Documents ordering parameters
- Generates OpenAPI/Swagger specification
For detailed Doc module documentation, see Doc module documentation.
Implementation Notes
Performance Considerations
Offset Pagination:
- Use for small datasets (< 10,000 items)
- Avoid large page numbers
- Slower with large offsets (DB must skip rows)
- Use when total count is important
Cursor Pagination:
- Better for large datasets
- Consistent performance (indexed lookup)
- Use for infinite scroll
- Avoids N+1 count queries