Implementing Pagination and Filtering for List Endpoints in Go: A Clean Architecture Guide
To implement pagination and filtering in the manakuro/golang-clean-architecture project, you define a UserListParams DTO in the use-case layer, propagate it through repository interfaces, and build dynamic GORM queries with Limit, Offset, and Where clauses based on HTTP query parameters.
This approach leverages Clean Architecture principles to keep your HTTP layer agnostic of database specifics while providing flexible list endpoints. The following implementation extends the existing GET /users endpoint to support optional pagination and field filtering without breaking existing contracts.
Understanding the Clean Architecture Flow
In this repository, requests flow through distinct layers: the controller handles HTTP concerns, the use case orchestrates business logic, and the repository manages data persistence. When implementing pagination and filtering for list endpoints in Go, each layer receives a data transfer object (DTO) containing the query parameters, ensuring that changes remain isolated to their respective domains.
The key is introducing a request-level struct that travels from the controller down to the repository, allowing the database layer to construct the appropriate SQL without the HTTP layer knowing about GORM or SQL specifics.
Step 1: Define the Pagination and Filtering DTO
Start by creating a struct that captures pagination fields (Page, Size) and filter criteria (Name, Email) in the use-case layer.
File: pkg/usecase/usecase/user.go
type UserListParams struct {
Page int // 1‑based page number
Size int // rows per page
Name string // optional name filter (partial match)
Email string // optional email filter (exact match)
}
Place this definition at the top of the file after the imports. This struct is pure data with no business logic, making it safe to pass across layer boundaries.
Step 2: Update the Use Case Interface and Implementation
Modify the User interface to accept the new parameters struct, then update the concrete implementation to forward these values to the repository.
File: pkg/usecase/usecase/user.go
Update the interface definition:
type User interface {
// List now receives pagination / filter params.
List(p UserListParams) ([]*model.User, error)
Create(u *model.User) (*model.User, error)
}
Update the concrete method:
func (uu *userUsecase) List(p UserListParams) ([]*model.User, error) {
// delegate to repository, passing the same DTO
return uu.userRepository.FindAll(p)
}
This change preserves the Dependency Rule by ensuring the use case depends only on abstractions, not concrete implementations.
Step 3: Adapt the Repository Interface
Extend the repository interface to accept the pagination and filtering DTO, allowing the use case to remain decoupled from database specifics.
File: pkg/usecase/repository/user.go
type UserRepository interface {
// FindAll receives pagination / filter params.
FindAll(p usecase.UserListParams) ([]*model.User, error)
Create(u *model.User) (*model.User, error)
}
Note that you must import the usecase package to reference UserListParams. This interface definition ensures any repository implementation—whether GORM, SQLx, or mock—can satisfy the contract.
Step 4: Implement GORM Pagination and Filtering Logic
In the concrete repository, build a dynamic query using GORM's fluent API to apply filters and calculate the correct offset and limit.
File: pkg/adapter/repository/user.go
func (ur *userRepository) FindAll(p usecase.UserListParams) ([]*model.User, error) {
var users []*model.User
query := ur.db.Model(&model.User{})
// ---------- filtering ----------
if p.Name != "" {
// Partial match on name (ILIKE for case‑insensitive)
query = query.Where("name ILIKE ?", "%"+p.Name+"%")
}
if p.Email != "" {
query = query.Where("email = ?", p.Email)
}
// ---------- pagination ----------
// Default size = 20, page = 1
size := p.Size
if size <= 0 {
size = 20
}
page := p.Page
if page <= 0 {
page = 1
}
offset := (page - 1) * size
query = query.Limit(size).Offset(offset)
// Execute the query
if err := query.Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
This implementation handles default values for missing parameters and constructs the SQL query conditionally, ensuring you only filter when parameters are provided.
Step 5: Parse Query Parameters in the Controller
Extract the pagination and filter values from the URL query string and map them into the UserListParams DTO before calling the use case.
File: pkg/adapter/controller/user.go
func (uc *userController) GetUsers(ctx Context) error {
// Extract pagination & filter values from the URL query string.
// Echo’s Context implements QueryParam(name string) string.
page, _ := strconv.Atoi(ctx.QueryParam("page"))
size, _ := strconv.Atoi(ctx.QueryParam("size"))
name := ctx.QueryParam("name")
email := ctx.QueryParam("email")
params := usecase.UserListParams{
Page: page,
Size: size,
Name: name,
Email: email,
}
users, err := uc.userUsecase.List(params)
if err != nil {
return err
}
return ctx.JSON(http.StatusOK, users)
}
Add the strconv import to handle string-to-integer conversion for the numeric parameters. This layer is responsible only for transport concerns—parsing HTTP requests and formatting responses.
Step 6: Verify the Router Configuration
No changes are required to the router because the existing GET /users endpoint already routes to the updated controller method.
File: pkg/infrastructure/router/router.go
e.GET("/users", func(context echo.Context) error { return c.User.GetUsers(context) })
The endpoint now implicitly supports query strings like ?page=2&size=10&name=alice without requiring new route definitions.
Usage Examples
Request with pagination only:
GET /users?page=2&size=10 HTTP/1.1
Host: localhost:8080
Returns users 11–20 assuming a page size of 10.
Request with filtering and pagination:
GET /users?name=alice&size=5 HTTP/1.1
Host: localhost:8080
Returns up to 5 users whose name contains "alice" (case-insensitive), starting from page 1.
cURL example:
curl "http://localhost:8080/users?page=1&size=15&email=jane@example.com"
Summary
- Define a
UserListParamsstruct inpkg/usecase/usecase/user.goto transport pagination and filter data across layers. - Extend the use-case interface and implementation to accept and forward this DTO to the repository.
- Update the repository interface in
pkg/usecase/repository/user.goto declare the new method signature. - Implement dynamic SQL construction in
pkg/adapter/repository/user.gousing GORM'sWhere,Limit, andOffsetmethods. - Parse query parameters in
pkg/adapter/controller/user.goand map them to the DTO before invoking the use case.
This pattern maintains strict separation of concerns while adding powerful querying capabilities to your list endpoints.
Frequently Asked Questions
How do I set default values for pagination parameters?
The repository implementation in pkg/adapter/repository/user.go handles defaults by checking if Size or Page are less than or equal to zero, defaulting to 20 items per page and page 1 respectively. This ensures the database query always receives valid integers even when the HTTP client omits the parameters.
Where should I validate pagination input parameters?
Validation should occur in the controller layer (pkg/adapter/controller/user.go) or a dedicated validator package before creating the UserListParams DTO. Check for reasonable limits—such as maximum page sizes—to prevent resource exhaustion, then return appropriate HTTP 400 responses for invalid input.
Can I add sorting to this pagination implementation?
Yes. Add a SortBy and SortOrder field to the UserListParams struct, then append GORM's Order method to the query builder in pkg/adapter/repository/user.go. Validate the sort fields against a whitelist in the controller to prevent SQL injection through user-supplied sort parameters.
How does this pattern preserve Clean Architecture principles?
Each layer depends only on the abstractions defined in inner layers. The controller knows nothing about GORM, the use case knows nothing about HTTP, and the repository implements interface contracts defined by the use case. The UserListParams DTO acts as a boundary object that safely crosses these layers without leaking implementation details.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →