Skip to content

Catalog Search

Admin lists need page numbers, mobile apps need infinite scroll, and batch jobs need to traverse all data. Among the three pagination approaches, which should you choose?

This chapter compares Offset, Cursor, and Stream pagination through the Catalog search domain. The same Specification performs all three query types, and you’ll learn each approach’s characteristics and suitable use scenarios.


After completing this chapter, you will be able to:

  1. Explain the differences among Offset, Cursor, and Stream pagination
  2. Implement various filter conditions with Specification composition
  3. Verify that the same Specification works across all Query methods
  4. Select suitable scenarios based on each approach’s performance characteristics

Refer to the table below when deciding which approach to choose.

CharacteristicSearch (Offset)SearchByCursor (Keyset)Stream
Total CountOXX
Random Page AccessOXX
Deep Page PerformanceO(N)O(1)N/A
Memory UsagePer pagePer pagePer item
Suitable ScenarioUI listsInfinite scrollBatch processing

All three query approaches use the same Specification. This is because filtering (“what to query”) and pagination (“how to split results”) are separated.

// In stock AND price range 30,000~100,000
var spec = new InStockSpec() & new PriceRangeSpec(30_000m, 100_000m);
// Same Specification for all 3 query types
await query.Search(spec, page, sort); // Offset
await query.SearchByCursor(spec, cursor, sort); // Cursor
query.Stream(spec, sort); // Stream

Each approach returns different result types and handles next-page processing differently. Choose the approach that fits your purpose.

// 1. Offset: Provides TotalCount, access by page number
var paged = await query.Search(spec, new PageRequest(1, 20), sort);
// paged.TotalCount, paged.TotalPages, paged.HasNextPage
// 2. Cursor: Next page via HasMore + NextCursor
var cursor = await query.SearchByCursor(spec, new CursorPageRequest(pageSize: 20), sort);
// cursor.HasMore, cursor.NextCursor -> pass as after in next request
// 3. Stream: Consume one by one with await foreach
await foreach (var item in query.Stream(spec, sort, ct))
{
Process(item); // Large-scale processing without memory burden
}

Check each file’s role in the pagination comparison.

FileRole
ProductId.csUlid-based product identifier
Product.csCatalog product Aggregate
ProductDto.csQuery-side DTO
InStockSpec.csStock > 0 Specification
PriceRangeSpec.csPrice range Specification
InMemoryCatalogQuery.csAll 3 Query method implementations

A summary of the pagination patterns used in this example.

ConceptImplementation
Offset paginationSearch(spec, PageRequest, SortExpression) -> PagedResult<T>
Cursor paginationSearchByCursor(spec, CursorPageRequest, SortExpression) -> CursorPagedResult<T>
Async streamStream(spec, SortExpression) -> IAsyncEnumerable<T>
Specification compositionnew InStockSpec() & new PriceRangeSpec(min, max)
Unified Query AdapterInMemoryCatalogQuery : InMemoryQueryBase<Product, ProductDto>

A: It depends on UI requirements. Offset suits admin lists (page numbers needed), Cursor suits mobile infinite scroll. InMemoryQueryBase implements both approaches, so the Usecase just chooses.

A: It’s suitable for batch jobs that need to traverse all data, such as CSV export, data migration, and statistical aggregation. Since IAsyncEnumerable<T> yields one record at a time, the entire dataset isn’t loaded into memory.

Q3: Why does the same Specification work across all three approaches?

Section titled “Q3: Why does the same Specification work across all three approaches?”

A: Specification addresses the concern of “what to filter,” while pagination addresses the concern of “how to split results.” Because these two concerns are separated, the same conditions can be freely combined with different approaches.


Through four domain examples, we’ve completed practical application of the CQRS pattern. The appendix provides additional reference materials including CQRS vs CRUD comparison, Repository vs Query adapter selection guide, and anti-patterns.

-> Appendix A: CQRS vs Traditional CRUD