Building REST APIs

Pagination and Filtering

ReadingPreview

আপনি একটি free preview lesson দেখছেন।

Collection endpoints দেখতে simple:

GET /products
GET /orders

কিন্তু production system-এ collection endpoint-এর সবচেয়ে common design mistakes-এর একটি হলো:

return everything

Development database-এ যদি মাত্র 12টি Order থাকে, সমস্যা দেখা যায় না।

কিন্তু কয়েক মাস পরে যদি থাকে:

10,000

100,000

1,000,000+

তাহলে একই endpoint:

database

application memory

JSON serialization

network bandwidth

client rendering

সবকিছুর উপর unnecessary pressure তৈরি করতে পারে।

আমাদের requirements শুরু থেকেই বলেছে:

Product browsing
→ bounded

Order history
→ bounded

এই lesson-এ আমরা collection API design করব যাতে:

results bounded থাকে

client navigation predictable হয়

filters explicit থাকে

database query eventually pagination-aware হয়

ownership/security rules bypass না হয়

এই lesson-এর goal:

Collection endpoints-কে এমনভাবে design করা যাতে API কখনো accidental unbounded query না হয়ে যায় এবং pagination/filtering public contract-এর deliberate অংশ হয়।


Why Pagination Exists

Suppose:

GET /orders

returns every Order belonging to a customer।

A customer initially has:

3 Orders

Everything looks fine।

Later:

500 Orders

Still perhaps manageable।

But an admin endpoint may have:

2,000,000 Orders

Returning all of them in one request would mean:

PostgreSQL loads huge result

application creates many objects

JSON response becomes huge

request takes longer

more heap memory is consumed

client receives data it cannot display at once

Pagination places an explicit upper bound on one request।


Pagination Is Part of API Design

Pagination is not merely:

Repository implementation detail

because client needs to know:

How many items can I request?

How do I request the next set?

What metadata is returned?

What happens when requested size is too large?

So pagination becomes part of:

HTTP request

HTTP response

Repository query

Our Collection Endpoints

The main v1 collection endpoints are:

GET /products

for customer Product browsing।

And:

GET /orders

for customer Order history or authorized admin Order browsing।

Potentially:

GET /inventory

for admin Inventory management।

All of these should be bounded।


A Simple Pagination Contract

For this course, a straightforward page-based API is enough।

For example:

GET /products?page=0&size=20

Meaning:

page
→ which page of results

size
→ maximum items per page

We will use:

zero-based page numbering

in our conceptual API examples।

So:

page=0
→ first page

page=1
→ second page

page=2
→ third page

The exact contract must be documented consistently।


Why Choose Page-Based Pagination?

There are several pagination strategies:

offset/page pagination

cursor pagination

keyset pagination

Each has different trade-offs।

Our current Commerce Order Management Backend does not yet require the complexity of cursor-based pagination।

For a foundational backend course, page-based pagination gives us a clear way to learn:

request bounds

database pagination

sorting

response metadata

without prematurely adding distributed-feed complexity।


Cursor Pagination Is Not "Always Better"

You may hear:

Production APIs should always use cursors.

That is too absolute।

Cursor/keyset pagination becomes especially useful for:

very large datasets

high-write feeds

stable sequential navigation

avoiding deep OFFSET cost

But it also introduces:

opaque cursor design

sorting constraints

navigation semantics

cursor encoding

additional API complexity

We should use it when requirements justify it।

Our current v1 does not।


Basic Request

A Product browsing request:

GET /products?page=0&size=20

An Order history request:

GET /orders?page=0&size=20

These parameters belong to the query string because they modify how a collection is viewed rather than identifying a different resource।


Pagination Defaults

Clients should not be required to always supply pagination values।

For example:

GET /products

could behave as:

page = 0

size = 20

The exact numbers are an API design decision।

For our examples, we can use:

default page = 0

default size = 20

as a reasonable teaching contract।

The more important principle is that a default request remains bounded।


Maximum Page Size

Allowing:

GET /orders?size=10000000

would defeat the purpose of pagination।

Therefore server needs an upper bound।

For example:

maximum size = 100

is a reasonable API guard for this course।

Then:

GET /orders?size=500

should not result in 500 rows simply because client asked for them।


Reject or Clamp?

Two common strategies exist।

Reject

Client requests:

size = 500

maximum is:

100

Server returns:

400 Bad Request

This makes invalid input explicit।


Clamp

Client asks for:

500

server silently uses:

100

This is more forgiving but less transparent।

For our API, explicit validation is easier to reason about:

size must be within allowed bounds

Invalid values should return our canonical validation problem।


Example Invalid Pagination Response

Request:

GET /api/v1/products?page=0&size=500

Response:

400 Bad Request
Content-Type: application/problem+json
{
  "type": "https://api.liveklass.io/problems/validation-error",
  "title": "Validation failed",
  "status": 400,
  "detail": "One or more fields are invalid.",
  "instance": "/api/v1/products",
  "code": "VALIDATION_ERROR",
  "errors": [
    {
      "field": "size",
      "code": "INVALID_VALUE",
      "message": "Size must be between 1 and 100."
    }
  ]
}

This follows our canonical LiveKlass error contract।


Valid Pagination Rules

Conceptually:

page >= 0

size >= 1

size <= 100

These are transport/API contract rules।

They can be validated before the UseCase performs the query।


A Pagination Request Model

Instead of passing loose primitives everywhere, we may eventually define an application-level value such as:

public record PageRequest(
        int page,
        int size
) {
}

But be careful with naming because Spring Data already has its own:

PageRequest

type।

We don't want application code accidentally coupled to Spring Data merely because the concept has the same name।

A project-specific name such as:

public record PageQuery(
        int page,
        int size
) {
}

could be clearer if we need such an abstraction।


Do We Need a Pagination Class Yet?

Not necessarily।

A Handler could simply call:

browseProducts.execute(
        page,
        size
);

for a small UseCase।

Introduce:

PageQuery

when several application operations benefit from the same application-level concept।

Do not create abstractions merely because pagination exists।


Handler Example

Conceptually:

@GetMapping
public ProductPageResponse browse(
        @RequestParam(
                defaultValue = "0"
        )
        int page,

        @RequestParam(
                defaultValue = "20"
        )
        int size
) {
    ...
}

But validation still needs to ensure:

page >= 0

1 <= size <= 100

Method-Level Validation

Spring can validate query parameters as well, depending on configuration։

Conceptually:

public ProductPageResponse browse(
        @RequestParam(
                defaultValue = "0"
        )
        @PositiveOrZero
        int page,

        @RequestParam(
                defaultValue = "20"
        )
        @Min(1)
        @Max(100)
        int size
) {
    ...
}

The exact Spring validation setup can be implemented when building the endpoint।

The important contract is already clear।


Response Needs Pagination Metadata

Returning only:

[
  {
    "id": "P-100"
  },
  {
    "id": "P-101"
  }
]

does not tell the client:

Which page is this?

How many results are in this page?

Is another page available?

A paginated response should provide useful metadata।


A Simple Page Response

Conceptually:

{
  "items": [
    {
      "id": "P-100",
      "name": "Keyboard",
      "price": 100.00
    },
    {
      "id": "P-101",
      "name": "Mouse",
      "price": 50.00
    }
  ],
  "page": 0,
  "size": 20,
  "totalItems": 42,
  "totalPages": 3
}

This is easy for clients to consume।


Generic Page Response?

We could create:

public record PageResponse<T>(
        List<T> items,
        int page,
        int size,
        long totalItems,
        int totalPages
) {
}

This is one of the cases where a generic shared technical type can provide real value।

Why?

Because pagination metadata is genuinely application-wide API infrastructure।

Product and Order responses can share the same pagination envelope semantics।


Shared Does Not Mean Dumping Ground

Earlier we said:

shared/

should exist only for real application-wide concepts।

A generic:

PageResponse<T>

may qualify because:

Product browsing

Order history

Inventory listing

all genuinely share the same transport pagination contract।

That's different from creating:

CommonUtils

for unrelated helpers।


Example Product Page

Conceptually:

public record ProductPageResponse(
        List<ProductResponse> items,
        int page,
        int size,
        long totalItems,
        int totalPages
) {
}

could work too।

Which is better?

If pagination shape is guaranteed to remain identical across capabilities, generic:

PageResponse<T>

avoids duplication।

If different collection APIs need different metadata later, capability-specific responses can evolve।

For our v1, a generic page envelope is reasonable।


Do We Need totalItems?

This is an important trade-off।

To return:

totalItems

totalPages

database often needs an additional:

COUNT(...)

query।

For many normal business screens this is fine।

For extremely large/high-throughput datasets, counting can become expensive।

Our current system does not have evidence that counts are a bottleneck।

So total counts provide useful client UX without premature optimization।


Don't Optimize Counts Before They Become a Problem

It would be premature to remove totals and design cursor pagination solely because:

COUNT queries can sometimes be expensive

Measure actual behaviour later।

For our current system:

page
size
totalItems
totalPages

is a clear practical contract।


Empty Page

Suppose:

GET /orders?page=10&size=20

but customer only has:

3 Orders

A reasonable response is:

200 OK

with:

{
  "items": [],
  "page": 10,
  "size": 20,
  "totalItems": 3,
  "totalPages": 1
}

Requesting a page beyond current data is not necessarily:

404

The collection exists।

That page simply contains no results।


Pagination Needs Stable Ordering

This is one of the most important rules।

Suppose database query has no explicit sorting:

SELECT *
FROM orders
LIMIT 20
OFFSET 20;

Relational databases do not guarantee business-meaningful row order without:

ORDER BY ...

Results can move unpredictably between pages।

Pagination must therefore define a stable ordering।


Order History Sorting

For customer Order history, a natural product requirement is generally:

newest Orders first

Once createdAt is part of the Order persistence model, the query can sort by:

createdAt descending

But timestamps alone may not always uniquely order rows।

Two Orders can theoretically share the same timestamp value։


Deterministic Tie-Breaking

A stronger sort can be:

createdAt DESC
OrderId DESC

or another deterministic identifier ordering compatible with the chosen ID strategy।

However, our exact Order ID strategy is still deferred।

The key lesson is:

Pagination ordering should be deterministic.

When persistence is implemented, we will choose an actual stable tie-breaker based on the identifier strategy।


Product Browsing Ordering

What is Product ordering?

Could be:

name

created time

price

admin-defined rank

Our requirements do not currently specify a customer-facing sort order।

Therefore don't invent:

alphabetical

or:

lowest price first

as permanent product behaviour yet।

We need a deterministic default, but exact business ordering should be chosen intentionally during implementation/design review।


Stable Does Not Mean Business-Specific Yet

We can say:

Product browse must have deterministic ordering.

without prematurely declaring:

ORDER BY name ASC

as a business rule।

Persistence ticket can choose/document the initial ordering once required context exists।


Filtering

Pagination answers:

Which bounded slice?

Filtering answers:

Which matching resources?

Examples might eventually include:

Order status

Product search

Inventory state

But we should only add filters that correspond to real use cases।


Avoid Filter Explosion

A common API mistake:

GET /orders?
status=...
&customerId=...
&minTotal=...
&maxTotal=...
&productId=...
&from=...
&to=...
&sort=...
&direction=...

before product requirements exist।

Every filter adds:

API contract

query complexity

index considerations

tests

documentation

Don't build speculative flexibility।


Customer Order History

Current customer requirement is:

view own Orders

At minimum we need:

GET /orders?page=0&size=20

No additional customer-controlled ownership filter is required।


Never Filter Customer Orders by Arbitrary Customer ID

Bad:

GET /orders?customerId=C-999

for a CUSTOMER endpoint।

This exposes ownership scope as query input।

Correct:

authenticated CustomerId
    ↓
UseCase
    ↓
Repository query

Pagination parameters are client-controlled।

Ownership scope is not।


Repository Query Conceptually

For a customer:

findOrdersByCustomer(
    authenticatedCustomerId,
    page,
    size
)

Not:

findOrders(
    request.customerId,
    page,
    size
)

This preserves authorization regardless of filters।


Admin Orders

Admin has broader visibility:

view all Orders

Admin endpoint may eventually benefit from filtering։

For example:

status

could become a legitimate admin query requirement।

But we should introduce it when the admin workflow needs it։

Don't assume a generic reporting system yet।


Status Filtering Example

If required later:

GET /orders?status=UNPAID&page=0&size=20

This can be a reasonable filter।

Handler should parse:

UNPAID

into an application/domain-friendly value such as:

OrderStatus.UNPAID

Invalid enum value should result in:

400 Bad Request

not silently return an empty list।


Invalid Filter Value

Suppose:

GET /orders?status=UNKNOWN

A consistent response:

{
  "type": "https://api.liveklass.io/problems/validation-error",
  "title": "Validation failed",
  "status": 400,
  "detail": "One or more fields are invalid.",
  "instance": "/api/v1/orders",
  "code": "VALIDATION_ERROR",
  "errors": [
    {
      "field": "status",
      "code": "INVALID_VALUE",
      "message": "Status is invalid."
    }
  ]
}

Again our canonical error contract applies to query parameters too।


Product Availability Filtering

Remember customer Product browsing already has semantic filtering:

active Product

AND available Inventory > 0

This is not necessarily a client query parameter।

It is part of what:

GET /products

means for customers।

Important distinction:

implicit resource contract filtering

versus:

client-controlled optional filter

GET /products?active=true Is Not Needed for Customers

Customers do not need to choose:

active=true

because inactive Products are not newly orderable by definition।

Making client request:

active=true

would expose implementation/business-state selection that the endpoint already owns।


Admin Product Filtering

An admin management API may eventually need:

active

inactive

filtering because admin must manage deactivated Products too।

That is a real different use case।

Again, introduce the filter when implementing admin browsing requirements rather than making customer API generic from day one।


Filtering Belongs in the Query, Not In Memory

Bad implementation:

load every Order for customer
    ↓
filter status in Java
    ↓
take 20

This defeats database capabilities and can load unbounded state։

Correct direction:

database query
    ↓
apply ownership/filter
    ↓
apply ordering
    ↓
apply page limit

Only the requested bounded results should normally reach the application।


Pagination Must Reach the Database

Another anti-pattern:

List<Order> orders =
        orderRepository.findAll();

return orders
        .stream()
        .skip(...)
        .limit(...)
        .toList();

This looks paginated from the client's perspective but database still returns everything।

That is not real pagination।

Later Repository/JPA queries should use database-level pagination।


Why Repository Owns Persistence Mechanics

UseCase should express:

I need this bounded page of the customer's Orders.

Repository/persistence implementation knows how that becomes:

LIMIT

OFFSET

ORDER BY

COUNT

or Spring Data pagination mechanisms।

The UseCase should not construct SQL。


Application Pagination Model vs Spring Data Page

When we introduce Spring Data JPA later, it provides:

Pageable

Page<T>

PageRequest

These are convenient persistence/framework types।

Should our Handler return:

Page<OrderEntity>

directly?

No।

That would expose:

Spring Data

JPA/persistence model

through the public API।


Don't Leak Page<T> Through the Architecture Automatically

A cleaner boundary:

Handler
    ↓
UseCase
    ↓
application pagination input/result
    ↓
Repository
    ↓
Spring Data Page internally

The persistence implementation can translate Spring Data concepts into application-owned models or values։

How much separation is needed depends on implementation complexity, but the public response should remain our:

PageResponse<T>

rather than framework-generated JSON։


Why Not Serialize Spring Page Directly?

Because its JSON structure may contain framework-specific fields such as:

pageable

sort

numberOfElements

first

last

that we may not want as our stable API contract।

Our API only needs the metadata we deliberately chose:

items

page

size

totalItems

totalPages

A Possible Application Page Type

If useful later:

public record PageResult<T>(
        List<T> items,
        int page,
        int size,
        long totalItems,
        int totalPages
) {
}

This could sit between Repository and Handler։

Then Handler maps:

PageResult<Product>

to:

PageResponse<ProductResponse>

But don't implement this abstraction until persistence/usecase work needs it।


Read Models May Avoid Full Domain Hydration

For collection endpoints, especially browsing, we may eventually use query projections।

For example Product browse needs perhaps:

ProductId

name

price

and eligibility may combine Inventory։

We may not need to load full rich Product + Inventory entities for every row just to serialize a list।

A query projection can be appropriate later।


This Is Not Anemic Domain Modeling

Using a read projection for efficient collection queries does not mean abandoning our domain model।

Commands/mutations still use rich domain behaviour։

Reads can use purpose-built representations where it improves efficiency and clarity։

This is a pragmatic distinction:

write behaviour
→ domain model

read-heavy projection
→ optimized query model when useful

No CQRS architecture is required to use a simple projection।


Avoid N+1 Queries

Pagination can still perform badly if each row triggers extra database queries։

For example:

load 20 Products

then for each Product:

query Inventory

Total:

1 Product query
+
20 Inventory queries

This is a classic:

N+1 query problem

We will cover JPA-specific causes later।

But endpoint design should already remind us that a page of 20 results should not casually become 21+ database round trips։


Product Browse Is a Good Example

Customer browse conceptually needs:

Product active

Inventory > 0

Product response data

Repository/query design later should support that efficiently in PostgreSQL।

We don't need internal HTTP calls or one query per Product।


Pagination and Concurrent Changes

Suppose customer fetches:

page 0

Then another Order is created before customer fetches:

page 1

With offset/page pagination, rows may shift।

A record could theoretically appear twice or be skipped depending on sorting and concurrent writes।

This is a known trade-off of offset pagination।


Is That Acceptable for Our Use Case?

For ordinary Product browsing and Order history, usually yes।

The API is not promising a frozen snapshot across multiple independent HTTP requests।

If requirements later demand:

perfectly stable traversal of a rapidly changing dataset

cursor/keyset pagination may become more appropriate।

Don't solve that future problem now।


Sorting and Pagination Are Connected

If clients can choose sorting later, every supported sort needs:

clear field semantics

deterministic ordering

database support

possibly indexes

So avoid generic:

GET /orders?sortBy=anything

that dynamically maps arbitrary strings to database columns।


Whitelist Supported Sorts

If sorting becomes a requirement, expose explicit supported values such as:

createdAt

rather than accepting arbitrary property/column names।

This avoids:

internal schema leakage

fragile reflection

unsafe query construction

Again, not needed yet for our initial customer Order history if a stable default sort is enough।


Filtering and Indexes

Every important database filter can influence indexing strategy։

For example:

customerId

status

createdAt

may become frequent Order query dimensions।

We will make actual index decisions in the PostgreSQL module based on real query patterns।

This is why API query design should come before random index creation।


API Query Drives Persistence Design

Example:

GET /orders
for authenticated customer
ordered newest first
paginated

tells us later that PostgreSQL may benefit from efficient access around:

customer ownership

ordering field

rather than guessing indexes before query requirements exist।


Pagination Response Example: Products

GET /api/v1/products?page=0&size=20

Response:

200 OK
{
  "items": [
    {
      "id": "P-100",
      "name": "Keyboard",
      "price": 100.00
    },
    {
      "id": "P-101",
      "name": "Mouse",
      "price": 50.00
    }
  ],
  "page": 0,
  "size": 20,
  "totalItems": 42,
  "totalPages": 3
}

Only currently orderable Products are represented according to customer browse semantics।


Pagination Response Example: Order History

GET /api/v1/orders?page=0&size=20

Response:

{
  "items": [
    {
      "id": "O-102",
      "status": "PAID",
      "total": 120.00
    },
    {
      "id": "O-101",
      "status": "CANCELLED",
      "total": 80.00
    }
  ],
  "page": 0,
  "size": 20,
  "totalItems": 2,
  "totalPages": 1
}

Only authenticated customer's Orders should appear for customer access।


Should List Responses Include Full Order Items?

Maybe not।

Order history could become unnecessarily large if every item is embedded into every row։

This can justify a future:

OrderSummaryResponse

for collection endpoint, while:

OrderResponse

provides full details for:

GET /orders/{orderId}

This is a useful API design optimization when list and detail needs differ।


Avoid Overfetching

Suppose 20 Orders each contain 10 OrderItems।

Returning all items means:

200 line items

just for one history page, even if UI only displays:

Order ID

date

status

total

API response should fit the use case।

So a realistic direction is:

GET /orders
→ summaries

GET /orders/{id}
→ full Order details

We can introduce the separate response model when implementing Order history concretely।


Filtering Does Not Override Security

Suppose admin later supports:

GET /orders?status=PAID

For customers, even if the same filter exists:

GET /orders?status=PAID

it must still mean:

my PAID Orders

not:

all PAID Orders

Security/ownership scope is always applied independently from optional filters։


Repository Queries Should Encode Scope Safely

Instead of:

query filters
    ↓
load Orders
    ↓
then remove unauthorized ones

prefer persistence/application query that scopes by authorized ownership from the beginning։

For example conceptually:

WHERE customer_id = authenticatedCustomerId
AND ...

This reduces both security risk and wasted data access।


No Client-Controlled Offset Without Bounds?

We could expose:

GET /orders?offset=100&limit=20

instead of page/size

That's also a valid pagination style।

But mixing:

page

offset

cursor

across endpoints creates unnecessary inconsistency।

For this course we'll keep:

page

size

as the public convention unless requirements later justify a change։


Beware Integer Overflow in Offset Calculation

If implementation calculates:

offset = page × size

using inappropriate numeric types and extreme inputs, overflow can theoretically occur।

Our API bounds size, and practical persistence libraries handle pagination calculations appropriately।

Still, never trust arbitrarily huge numeric parameters without validation।

A server may also need a reasonable maximum page/offset policy later if deep paging becomes operationally expensive।


Deep Pagination

Even with:

size = 20

requesting:

page = 500000

can lead to a very large database OFFSET।

Offset pagination can become inefficient deep into large datasets।

This is a real production trade-off।

But don't jump immediately to cursor pagination։

First ask:

Do our users actually navigate that deeply?

Is this an admin export problem rather than browsing?

What do query metrics show?

If it becomes a real bottleneck, change the design deliberately।


Pagination Is Not Data Export

If an admin eventually needs:

download all Orders

that is a different capability from:

browse Orders page by page

Don't increase normal API page size to:

100000

just to support exports։

Bulk export deserves its own design if required։


Filter Validation

Any filter should have explicit accepted values։

For example:

status

could accept only:

UNPAID

PAID

CANCELLED

Invalid value:

400 / VALIDATION_ERROR

Do not silently ignore:

status=SOMETHING

because client may falsely assume filtering was applied।


Unknown Query Parameters

Should:

GET /products?foo=bar

return 400?

Frameworks commonly ignore unbound query parameters unless custom handling is added।

Whether to reject unknown query parameters is a broader API policy։

We do not need strict unknown-parameter rejection for this course unless required by LiveKlass API standards։

Focus on validating supported parameters correctly।


Multiple Filter Values

Suppose future admin Orders need:

UNPAID + PAID

Should API support:

status=UNPAID,PAID

or repeated params?

No requirement exists yet।

Do not design a mini query language prematurely।

One filter can evolve later when actual UI/product requirements arrive։


Search

Product browsing may eventually need:

GET /products?query=keyboard

But "search" raises questions:

name only?

case sensitivity?

partial matching?

full-text search?

ranking?

Our current requirements only say browse currently orderable Products।

Search is not required yet।

Do not add it just because Product lists often have search boxes।


Filters Must Reflect Business Concepts

Prefer:

status=PAID

if OrderStatus is a public supported concept։

Avoid exposing internal DB filters such as:

payment_state_internal_code=3

or:

inventory_join_present=true

API query vocabulary should remain product-facing।


Collection API Errors

Collection requests can fail in several ways։

Invalid page:

GET /products?page=-1

400 / VALIDATION_ERROR

Invalid size:

GET /products?size=0

400 / VALIDATION_ERROR

Unauthorized collection access:

401 / 403

Unexpected database failure:

500 / INTERNAL_ERROR

Empty results:

200

not an error।


Example Multiple Pagination Errors

GET /api/v1/orders?page=-1&size=500

could return:

{
  "type": "https://api.liveklass.io/problems/validation-error",
  "title": "Validation failed",
  "status": 400,
  "detail": "One or more fields are invalid.",
  "instance": "/api/v1/orders",
  "code": "VALIDATION_ERROR",
  "errors": [
    {
      "field": "page",
      "code": "INVALID_VALUE",
      "message": "Page must be greater than or equal to 0."
    },
    {
      "field": "size",
      "code": "INVALID_VALUE",
      "message": "Size must be between 1 and 100."
    }
  ]
}

Consistent with body validation errors।


Do Not Return Pagination Metadata in Headers Only

Some APIs use headers such as:

X-Total-Count

This can work։

But keeping pagination metadata in the JSON response makes the contract explicit and easier for many clients to consume।

For our API, body-based metadata is sufficient।


Do We Need first, last, hasNext?

Potential response:

{
  "first": true,
  "last": false,
  "hasNext": true
}

These are derivable from:

page

totalPages

Adding all of them makes the response larger and more coupled without much value।

Our v1 keeps:

items

page

size

totalItems

totalPages

Simple and sufficient।


What If totalItems = 0?

Then a reasonable representation is:

{
  "items": [],
  "page": 0,
  "size": 20,
  "totalItems": 0,
  "totalPages": 0
}

This clearly communicates an empty collection।


Pagination Metadata Uses Server-Applied Size

If client omits:

size

and server applies default:

20

response should say:

{
  "size": 20
}

Metadata describes what the server actually applied।


Keep Response Field Naming Consistent

Use:

totalItems

totalPages

consistently across Product, Inventory, and Order pagination।

Avoid one endpoint returning:

totalItems

another:

count

and another:

total_elements

without an intentional API naming standard।


Handler Should Not Calculate totalPages

Avoid:

int totalPages =
        (int) Math.ceil(
                totalItems /
                (double) size
        );

scattered across Handlers։

Pagination result or page-response mapping should centralize that concern if the persistence layer doesn't already provide it։

Again, avoid duplicated transport mechanics։


Filtering and Domain Logic

Not every filter needs a rich domain Entity।

For example:

find PAID Orders

is a read query։

Repository can efficiently filter persisted Order status without loading every Order and invoking:

order.status()

Filtering a collection is generally a query concern।

Domain behaviour is more important when executing state-changing rules।


Pagination and Transactions

A normal paginated GET does not need the same transaction design as:

CreateOrderUseCase

where multiple writes must commit atomically।

Read consistency across:

COUNT query

and:

page query

can theoretically vary under concurrent writes depending on database isolation।

For ordinary browsing this is usually acceptable।

Do not create heavyweight snapshot transactions just to make pagination metadata perfectly frozen unless requirements demand it।


Pagination Tests

At the Handler/API level, useful tests include:

no pagination params
→ defaults applied

page = -1
→ 400

size = 0
→ 400

size > max
→ 400

empty result
→ 200 + empty items

valid page
→ expected metadata

UseCase Tests

For customer Order history:

authenticated CustomerId

must be passed to the query flow।

Test that requested filters/pagination do not replace ownership scope։


Repository Integration Tests

Later, with PostgreSQL/Testcontainers, test:

page boundaries

stable ordering

customer isolation

filter behaviour

total counts

These are important because pagination correctness depends heavily on the persistence query։


Test Boundary Cases

Suppose there are exactly:

21 Orders

with:

size = 20

Then:

page 0
→ 20 items

page 1
→ 1 item

totalItems = 21

totalPages = 2

Boundary tests catch common off-by-one bugs।


Stable Ordering Test

Given known Order creation times, integration test should verify:

newest first

once that ordering is officially selected।

Without testing ordering, pagination tests can become flaky or pass accidentally։


Do Not Depend on Insertion Order

PostgreSQL does not promise:

rows return in the order they were inserted

unless explicitly ordered।

Never write tests that assume database natural order is stable।


Common Mistake 1 — Unbounded findAll()

Collection APIs must remain bounded even if development data is tiny।


Common Mistake 2 — Slice in Java After Loading Everything

Pagination must reach the database query։


Common Mistake 3 — No Explicit Ordering

Pages become unpredictable।


Common Mistake 4 — Arbitrarily Huge Client Page Size

Server must enforce a maximum bound।


Common Mistake 5 — Customer Supplies customerId Filter

Ownership scope comes from authenticated identity, not query parameters։


Common Mistake 6 — Generic Filter for Every Entity Field

Expose only filters justified by actual product use cases։


Common Mistake 7 — Return JPA Page Directly

Framework pagination representation should not become accidental public JSON contract।


Common Mistake 8 — Treat Empty Collection as 404

Empty page/collection is normally a successful response։


Common Mistake 9 — In-Memory Filtering

Database should apply relevant filters before limiting results։


Common Mistake 10 — Cursor Pagination Without Need

Use more advanced pagination only when actual query scale/consistency requirements justify it।


Our V1 Pagination Contract

Current API direction:

page
→ zero-based
→ default 0
→ minimum 0
size
→ default 20
→ minimum 1
→ maximum 100

Response:

{
  "items": [],
  "page": 0,
  "size": 20,
  "totalItems": 0,
  "totalPages": 0
}

This shape can be reused consistently across bounded collection APIs।


Current Filtering Direction

Customer Product Browse

Implicit business scope:

active

AND Inventory > 0

No speculative client filters yet।


Customer Order History

Implicit security scope:

authenticated CustomerId

No client-controlled customer filter।

Additional filters are added only when requirements justify them।


Admin Collections

Potential filters may be introduced later based on actual admin workflows।

Do not design a generic query engine now।


Responsibility Map

Handler

Owns:

query parameter binding

pagination input validation

filter parsing

response DTO mapping

UseCase

Owns:

application query intent

authenticated ownership scope

business visibility rules

coordination across capabilities

Repository

Owns:

database filtering

ordering

LIMIT/OFFSET or framework equivalent

count query

efficient persistence access

Domain

Still owns:

business invariants

but does not need to perform collection pagination itself।


Engineering Principle

The core principle:

Pagination is only real when the data source is bounded—not when the application loads everything and slices the result afterward.

Another:

Client-controlled filters may narrow an authorized dataset, but they must never define the caller's authorization scope.

And:

Start with a small, predictable pagination contract and add advanced filtering, sorting, cursors, or search only when real requirements justify the additional complexity.


Summary

In this lesson, we learned that:

  • Collection endpoints must remain bounded as data grows.
  • Pagination is part of the public API contract, not merely a Repository implementation detail.
  • Our initial API uses page-based pagination.
  • page is zero-based with a default of 0.
  • size defaults to 20.
  • Page size must stay between 1 and 100.
  • Invalid pagination parameters should use the canonical VALIDATION_ERROR problem response.
  • Paginated responses can contain items, page, size, totalItems, and totalPages.
  • Empty collections and pages still return 200 OK.
  • Pagination requires explicit deterministic ordering.
  • Exact Product ordering will not be invented until the business/query contract requires it.
  • Customer Order history should eventually use a stable newest-first ordering with a deterministic tie-breaker.
  • Client-controlled filters should only be added for real use cases.
  • Customer Order ownership must come from authenticated CustomerId, never a customerId query parameter.
  • Customer Product browsing implicitly filters to currently orderable Products.
  • Filtering and pagination should happen in PostgreSQL, not after loading an unbounded collection into Java.
  • Repository implementations own database pagination mechanics.
  • Public APIs should not directly serialize Spring Data Page or persistence entities.
  • A generic PageResponse<T> can be a legitimate shared HTTP concept when pagination semantics are truly consistent.
  • Returning total counts is reasonable for our current system; we should optimize only if measurements show a real problem.
  • Offset pagination has known trade-offs under concurrent writes and deep pages, but they do not currently justify cursor pagination.
  • List responses may use smaller summary DTOs when full resource details would cause unnecessary overfetching.
  • Pagination/filtering tests should cover bounds, empty pages, ordering, ownership scope, and off-by-one cases.
  • API query patterns will later inform PostgreSQL index design.

Next lesson:

API Versioning

There we will define why our routes use /api/v1, what changes actually require a new API version, which changes can remain backward-compatible, and how to avoid creating /v2 every time the implementation changes.