Building REST APIs
Pagination and Filtering
আপনি একটি 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.
pageis zero-based with a default of0.sizedefaults to20.- Page size must stay between
1and100. - Invalid pagination parameters should use the canonical
VALIDATION_ERRORproblem response. - Paginated responses can contain
items,page,size,totalItems, andtotalPages. - 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 acustomerIdquery 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
Pageor 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.