Implementing Pagination, Filters, and Error Handling in a Go Clean Architecture with Ent and gqlgen (Part 3)
This tutorial walks through adding pagination and flexible where‑filters to the User List interface, configuring gqlgen extensions, handling GraphQL errors, wrapping mutations in transactions, and building unit, integration, and end‑to‑end tests for a Go clean‑architecture project using Ent and gqlgen.
This article continues the previous tutorial by adding pagination support to the User repository.
Pagination
First, create a List method in pkg/usercase/repository/user.go:
type User interface {
Get(ctx context.Context, id *model.ID) (*model.User, error)
List(ctx context.Context, after *model.Cursor, first *int, before *model.Cursor, last *int) (*model.UserConnection, error)
}Then forward the call through the use‑case layer ( pkg/usecase/usecase/user.go) and the controller layer ( pkg/adapter/controller/user.go).
func (u *user) List(ctx context.Context, after *model.Cursor, first *int, before *model.Cursor, last *int) (*model.UserConnection, error) {
return u.userRepository.List(ctx, after, first, before, last)
}Finally, implement the repository method ( pkg/adapter/repository/user.go) using Ent's pagination API:
func (r *userRepository) List(ctx context.Context, after *model.Cursor, first *int, before *model.Cursor, last *int) (*model.UserConnection, error) {
us, err := r.client.User.Query().Paginate(ctx, after, first, before, last)
if err != nil {
return nil, err
}
return us, nil
}The GraphQL resolver now calls the controller:
func (r *queryResolver) Users(ctx context.Context, after *ent.Cursor, first *int, before *ent.Cursor, last *int) (*ent.UserConnection, error) {
us, err := r.controller.User.List(ctx, after, first, before, last)
if err != nil {
return nil, err
}
return us, nil
}Filter Input
To add richer GraphQL filtering, enable Ent's where‑filter extension in ent/entc.go:
ex, err := entgql.NewExtension(
entgql.WithWhereFilters(true),
entgql.WithConfigPath("../gqlgen.yml"),
entgql.WithSchemaPath("../graph/ent.graphqls"),
)Run the generator: $ go generate ./ent This creates graph/ent.graphql with filter types.
Update the GraphQL schema ( graph/user.graphqls) to expose the where argument:
extend type Query {
user(id: ID): User
users(after: Cursor, first: Int, before: Cursor, last: Int, where: UserWhereInput): UserConnection
}Bind the generated type in the model layer:
// UserWhereInput represents a where input for filtering User queries.
type UserWhereInput = ent.UserWhereInputRegenerate the gqlgen code: $ gqlgen The resolver signature now includes the where parameter:
func (r *queryResolver) Users(ctx context.Context, after *ent.Cursor, first *int, before *ent.Cursor, last *int, where *ent.UserWhereInput) (*ent.UserConnection, error) {
// implementation will use the where filter
}Propagate the filter through the repository interface:
type User interface {
...
List(ctx context.Context, after *model.Cursor, first *int, before *model.Cursor, last *int, where *model.UserWhereInput) (*model.UserConnection, error)
}Implement the repository method using Ent's filter helper:
func (r *userRepository) List(ctx context.Context, after *model.Cursor, first *int, before *model.Cursor, last *int, where *model.UserWhereInput) (*model.UserConnection, error) {
us, err := r.client.User.Query().Paginate(ctx, after, first, before, last, ent.WithUserFilter(where.Filter))
if err != nil {
return nil, err
}
return us, nil
}Finally, the resolver forwards the filter to the controller:
func (r *queryResolver) Users(ctx context.Context, after *ent.Cursor, first *int, before *ent.Cursor, last *int, where *ent.UserWhereInput) (*ent.UserConnection, error) {
us, err := r.controller.User.List(ctx, after, first, before, last, where)
if err != nil {
return nil, err
}
return us, nil
}Example query for users older than 30:
Error Handling
All gqlgen resolvers should return errors via graphql.AddError:
graphql.AddError(ctx, gqlerror.Errorf("error1!"))
graphql.AddError(ctx, gqlerror.Errorf("error2!"))The response will contain an errors array preserving order:
{
"data": {"todo": null},
"errors": [
{"message": "error1!", "path": ["todo"]},
{"message": "error2!", "path": ["todo"]}
]
}A unified error model is defined in pkg/entity/model/error.go with codes such as DB_ERROR, GRAPHQL_ERROR, etc., and helper constructors that wrap errors with stack traces using github.com/pkg/errors.
func NewDBError(e error) error { ... }
func NewGraphQLError(e error) error { ... }
// ... other constructors
func HandleError(ctx context.Context, err error) error {
var extendedError interface{ Extensions() map[string]interface{} }
for err != nil {
u, ok := err.(interface{ Unwrap() error })
if !ok { break }
if model.IsStackTrace(err) { err = u.Unwrap(); continue }
if !model.IsError(err) { err = u.Unwrap(); continue }
gqlerr := &gqlerror.Error{Path: graphql.GetPath(ctx), Message: err.Error()}
if errors.As(err, &extendedError) { gqlerr.Extensions = extendedError.Extensions() }
graphql.AddError(ctx, gqlerr)
err = u.Unwrap()
}
return nil
}Use this handler in mutation resolvers:
func (r *mutationResolver) CreateUser(ctx context.Context, input ent.CreateUserInput) (*ent.User, error) {
u, err := r.controller.User.Create(ctx, input)
if err != nil {
return nil, handler.HandleError(ctx, err)
}
return u, nil
}When a name exceeds the allowed length, the GraphQL response contains an error (illustrated by screenshots).
Transactional Mutations
Ent can automatically wrap each GraphQL mutation in a database transaction. Add the transactioner to the server configuration: srv.Use(entgql.Transactioner{TxOpener: client}) Utility to retrieve the transactional client:
func WithTransactionalMutation(ctx context.Context) *ent.Client { return ent.FromContext(ctx) }Example of creating a user and a todo within a transaction:
func (r *userRepository) CreateWithTodo(ctx context.Context, input model.CreateUserInput) (*model.User, error) {
client := WithTransactionalMutation(ctx)
todo, err := client.Todo.Create().Save(ctx)
if err != nil { return nil, model.NewDBError(err) }
u, err := client.User.Create().SetInput(input).AddTodos(todo).Save(ctx)
if err != nil { return nil, model.NewDBError(err) }
return u, nil
}If the user creation fails, the transaction is rolled back (illustrated by a screenshot).
Testing
Unit tests use a real MySQL database instead of mocks. The test database is reset with SQL scripts placed under docker/mysql_date/sql/. Example reset script:
DROP DATABASE IF EXISTS golang_clean_architecture_ent_gqlgen_test;
CREATE DATABASE golang_clean_architecture_ent_gqlgen_test CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;Helper scripts ( bin/init_db_test.sh) copy the SQL file into the container and execute it.
Test utilities ( testutil/config.go, testutil/database.go) load configuration, create an Ent test client, and provide functions to drop tables.
Repository tests follow the Arrange‑Act‑Assert pattern with table‑driven cases. Example test verifies that List returns three users:
func TestUserRepository_List(t *testing.T) {
// arrange: create three users in the test DB
// act: call repo.List with first=5
// assert: err is nil and len(got.Edges) == 3
}End‑to‑end (E2E) tests use github.com/gavv/httpexpect/v2 to send HTTP POST requests to the GraphQL endpoint. The E2E database is set up similarly with its own reset script.
Utility package testutil/e2e/e2e.go provides functions to start the server, create an httpexpect.Expect client, and extract data or errors from responses.
Example E2E test creates a user via a GraphQL mutation and asserts the returned fields:
expect.POST(router.QueryPath).WithJSON(map[string]string{"query": `
mutation {
createUser(input: {name: "Tom1", age: 20}) {
age name id createdAt updatedAt
}
}`}).Expect()
// assertions on status, data.age == 20, data.name == "Tom1"A second test verifies that a name exceeding the length limit results in a GraphQL error array of length 1.
Conclusion
The article demonstrates how to extend a Go clean‑architecture project with pagination, flexible filtering, robust error handling, transactional mutations, and comprehensive testing (unit, integration, and E2E) using Ent, gqlgen, and standard Go testing tools.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Code DAO
We deliver AI algorithm tutorials and the latest news, curated by a team of researchers from Peking University, Shanghai Jiao Tong University, Central South University, and leading AI companies such as Huawei, Kuaishou, and SenseTime. Join us in the AI alchemy—making life better!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
