Appearance
Custom Resolvers
This guide covers how to extend DDK-generated servers with custom business logic using custom resolvers.
Table of Contents
- When to Use Custom Resolvers
- Custom Resolver Structure
- Resolver Directives
- Implementation Workflow
- Context Values Available in Resolvers
- Using Generated Repositories
- Query Resolvers
- Mutation Resolvers
- Subscription Resolvers
- Error Handling
- How Resolver Preservation Works
- Generated Test Scaffolding
- Best Practices
- Common Patterns
- Testing
When to Use Custom Resolvers
Generated CRUD operations cover many scenarios, but custom resolvers are needed for:
The file-level separation between generated resolvers (*.resolvers.go) and custom resolvers (*.custom.resolvers.go) is a pattern the DDK introduced to solve a problem that defeats most code generators: how do you keep generating code over time without destroying the business logic the team has added since the last generation? The conventional answers are unsatisfying — either you generate once and hand-edit everything from then on (losing the ability to regenerate), or you keep all business logic outside the generated files (which often means awkward hook systems or complex layering). The DDK's approach is simpler: custom files are never touched by the generator. The naming convention (*.custom.resolvers.go) is the contract. A developer who writes business logic in a *.custom.resolvers.go file knows it will survive indefinitely. A developer who edits a *.resolvers.go file knows that regeneration will overwrite their changes. The contract is visible in the filename, which means it does not require documentation to understand once you have seen it once.
Complex Business Logic
- Multi-step workflows
- Complex validation rules
- Conditional processing
- Business rule enforcement
Advanced Queries
- Queries spanning multiple tables
- Aggregations and analytics
- Full-text search
- Complex filtering with custom WHERE clauses
External Integrations
- Third-party API calls
- External authentication
- Payment processing
- Email/notification services
Computed Fields
- Calculated values
- Derived data
- Runtime transformations
Custom Operations
- Batch operations
- Import/export
- Report generation
- Data migration
Custom Resolver Structure
Custom resolvers require separating your schema into two files:
1. ORM Schema ({name}.orm.graphqls)
Defines database-backed types with auto-generated CRUD operations:
graphql
type User @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
email: String! @constraint(type: "unique")
password: String!
age: Int
}2. Custom Schema ({name}.custom.graphqls)
Defines custom operations that you'll implement manually:
graphql
extend type Query {
getUserByEmail(email: String!): User! @resolver(type: "CUSTOM")
searchUsers(query: String!, limit: Int): [User]! @resolver(type: "CUSTOM")
}
extend type Mutation {
registerUser(name: String!, email: String!, password: String!): User!
@resolver(type: "CUSTOM")
}Key Points:
- Use
extend typeto add to Query/Mutation/Subscription - Mark with
@resolver(type: "CUSTOM")directive - Operations can return ORM types
- Can define entirely new types for custom operations
- The
*.custom.graphqlsfiles are excluded from auto-resolver generation (only processed by gqlgen)
Resolver Directives
@resolver Directive
Marks custom query and mutation resolvers. The CustomResolverPlugin in hooks/custom_resolver.go checks for this directive by looking for Args[0].Value == "CUSTOM" on each field's directives.
Syntax:
graphql
@resolver(type: "CUSTOM")Usage:
graphql
extend type Query {
# Simple query
getActiveUsers: [User]! @resolver(type: "CUSTOM")
# Query with parameters
findUsersByAge(minAge: Int!, maxAge: Int!): [User]! @resolver(type: "CUSTOM")
}
extend type Mutation {
# Simple mutation
resetAllPasswords: Boolean! @resolver(type: "CUSTOM")
# Mutation with parameters
promoteToAdmin(userId: ID!): User! @resolver(type: "CUSTOM")
}@subscriber Directive
Marks custom subscription resolvers.
Syntax:
graphql
@subscriber(type: "CUSTOM")Usage:
graphql
extend type Subscription {
userUpdated(userId: ID): User! @subscriber(type: "CUSTOM")
messageReceived(channelId: ID!): Message! @subscriber(type: "CUSTOM")
}Implementation Workflow
Step 1: Define Custom Operations
Create or update your .custom.graphqls file:
graphql
extend type Query {
getUserByEmail(email: String!): User! @resolver(type: "CUSTOM")
}
extend type Mutation {
registerUser(name: String!, email: String!, password: String!): User!
@resolver(type: "CUSTOM")
}Step 2: Generate Server
Run the ReGenerateServer gRPC method. The DDK's CustomResolverPlugin iterates through Query/Mutation/Subscription fields, checks for the CUSTOM directive, and generates stub files only if they don't already exist:
go
// From custom_resolver.go:
customFile := fmt.Sprintf("./graph/resolver/%s.custom.resolvers.go", strings.ToLower(resolver.GoFieldName))
_, err := os.Stat(customFile)
if err == nil {
continue // File exists — skip, don't overwrite
}Generated files:
graph/resolver/
├── getuserbyemail.custom.resolvers.go # Resolver stub
├── registeruser.custom.resolvers.go # Resolver stub
test/
├── getuserbyemail.custom.resolvers_test.go # Test stub
├── registeruser.custom.resolvers_test.go # Test stubStep 3: Implement Resolver Logic
Edit the generated stub files. The generated stub returns "method not implemented" by default:
go
// Generated stub (from custom_resolver.go.tpl):
func (r *queryResolver) resolver_GetUserByEmail(
ctx context.Context,
email string,
) (model.User, error) {
var target model.User
return target, errors.New("method not implemented")
}Replace with your implementation:
go
func (r *queryResolver) resolver_GetUserByEmail(
ctx context.Context,
email string,
) (model.User, error) {
tx := ctx.Value("tx").(*gorm.DB)
var user model.User
err := tx.Where("email = ?", email).First(&user).Error
if err != nil {
return model.User{}, err
}
return user, nil
}Step 4: Regenerate (Optional)
If you need to add more custom operations:
- Update
.custom.graphqls - Run
ReGenerateServer - Existing implementations are preserved — the plugin skips files that already exist
- New stub files are created only for new operations
Context Values Available in Resolvers
The middleware chain stores several values in the request context. These are available in all resolvers (both generated and custom):
| Context Key | Type | Source | Description |
|---|---|---|---|
"tx" | *gorm.DB | TXMiddleware | Active database transaction with "preTransaction" savepoint |
"jwt" | string | JwtMiddleware | Raw JWT token from Authorization header |
"jwt-claims" | jwt.MapClaims | JwtMiddleware | Parsed JWT claims map (unverified) |
"logger" | *zap.Logger | ZapMiddleware | Per-request Zap structured logger with trace ID |
"resolverTrace" | string | ZapMiddleware | 32-byte random trace ID for request correlation |
Usage Example:
go
func (r *queryResolver) resolver_GetCurrentUser(ctx context.Context) (model.User, error) {
// Get transaction
tx := ctx.Value("tx").(*gorm.DB)
// Get JWT claims
claims := ctx.Value("jwt-claims").(jwt.MapClaims)
userID := claims["sub"].(string)
// Get logger
logger := ctx.Value("logger").(*zap.Logger)
logger.Info("fetching current user", zap.String("userID", userID))
var user model.User
err := tx.First(&user, "id = ?", userID).Error
return user, err
}Using Generated Repositories
The resolver has access to the RepositoryHolder which contains typed repositories for each table entity. These provide a rich interface instead of raw GORM queries.
Accessing Repositories
The resolver struct (in resolver.go) holds references to all DDD layers:
go
type Resolver struct {
Repo repository.RepositoryHolder // Typed repositories
RepoOld impl.Repository // Legacy repository
Srv service.Service // Service layer
Ctrl controller.ControllerHolder // Controllers
Redis *redis.Client // Redis connection
Schema *ast.Schema // GraphQL AST schema
}Repository Methods
Each typed repository provides 15+ methods (see Architecture):
go
func (r *queryResolver) resolver_GetActiveUsers(ctx context.Context) ([]*model.User, error) {
tx := ctx.Value("tx").(*gorm.DB)
var users []*model.User
err := r.Repo.UserRepository.GetWhere(
&users,
`"isActive" = true`,
&repository.GormOptions{
Tx: tx,
OrderBy: []repository.OrderBy{{Field: "name", Desc: false}},
},
)
return users, err
}GormOptions
Pass options to control query behavior:
go
opts := &repository.GormOptions{
Tx: tx, // Use request transaction
Preloads: []string{"Posts", "Profile"}, // Eager-load relationships
Debug: true, // Log SQL queries
OrderBy: []repository.OrderBy{
{Field: "createdAt", Desc: true},
},
}Available Repository Methods
| Method | Description |
|---|---|
GetAll(target, opts) | Get all records |
GetBatch(target, limit, offset, opts) | Get paginated records |
GetWhere(target, condition, opts) | Get records matching SQL condition |
GetWhereBatch(target, condition, limit, offset, opts) | Paginated WHERE query |
GetByField(target, field, value, opts) | Get records by single field |
GetByFields(target, filters, opts) | Get records by multiple fields (supports IN for slices) |
GetByFieldBatch(target, field, value, limit, offset, opts) | Paginated field query |
GetByFieldsBatch(target, filters, limit, offset, opts) | Paginated multi-field query |
GetOneByField(target, field, value, opts) | Get single record by field |
GetOneByFields(target, filters, opts) | Get single record by multiple fields |
GetOneByID(target, id, opts) | Get single record by primary key |
Create(target, opts) | Create a record |
CreateBatch(target, batchSize, opts) | Batch create records |
Save(target, opts) | Update a record (errors if not found) |
Delete(target, opts) | Delete a record (errors if not found) |
GetObjectName() | Get the table/type name |
GetPrimaryKey() | Get the primary key field name |
Query Resolvers
Basic Query
Schema:
graphql
extend type Query {
getActiveUsers: [User]! @resolver(type: "CUSTOM")
}Implementation:
go
func (r *queryResolver) resolver_GetActiveUsers(
ctx context.Context,
) ([]*model.User, error) {
tx := ctx.Value("tx").(*gorm.DB)
var users []*model.User
err := tx.Where("is_active = ?", true).Find(&users).Error
return users, err
}Query with Parameters
Schema:
graphql
extend type Query {
findUsersByAge(minAge: Int!, maxAge: Int!): [User]! @resolver(type: "CUSTOM")
}Implementation:
go
func (r *queryResolver) resolver_FindUsersByAge(
ctx context.Context,
minAge int,
maxAge int,
) ([]*model.User, error) {
tx := ctx.Value("tx").(*gorm.DB)
var users []*model.User
err := tx.Where("age BETWEEN ? AND ?", minAge, maxAge).
Find(&users).Error
return users, err
}Query with Optional Parameters
Schema:
graphql
extend type Query {
searchUsers(name: String, email: String, minAge: Int): [User]!
@resolver(type: "CUSTOM")
}Implementation:
go
func (r *queryResolver) resolver_SearchUsers(
ctx context.Context,
name *string,
email *string,
minAge *int,
) ([]*model.User, error) {
tx := ctx.Value("tx").(*gorm.DB)
query := tx
if name != nil {
query = query.Where("name ILIKE ?", "%"+*name+"%")
}
if email != nil {
query = query.Where("email = ?", *email)
}
if minAge != nil {
query = query.Where("age >= ?", *minAge)
}
var users []*model.User
err := query.Find(&users).Error
return users, err
}Query with Repository Methods
Schema:
graphql
extend type Query {
getUsersByRole(role: String!, limit: Int, offset: Int): [User]!
@resolver(type: "CUSTOM")
}Implementation using typed repository:
go
func (r *queryResolver) resolver_GetUsersByRole(
ctx context.Context,
role string,
limit *int,
offset *int,
) ([]*model.User, error) {
tx := ctx.Value("tx").(*gorm.DB)
l := 100
o := 0
if limit != nil { l = *limit }
if offset != nil { o = *offset }
var users []*model.User
err := r.Repo.UserRepository.GetByFieldBatch(
&users,
"role",
role,
l, o,
&repository.GormOptions{Tx: tx},
)
return users, err
}Query with Relationships
Schema:
graphql
extend type Query {
getUserWithPosts(userId: ID!): User! @resolver(type: "CUSTOM")
}Implementation:
go
func (r *queryResolver) resolver_GetUserWithPosts(
ctx context.Context,
userId string,
) (model.User, error) {
tx := ctx.Value("tx").(*gorm.DB)
var user model.User
err := tx.Preload("Posts").First(&user, "id = ?", userId).Error
return user, err
}Mutation Resolvers
Basic Mutation
Schema:
graphql
extend type Mutation {
activateUser(userId: ID!): User! @resolver(type: "CUSTOM")
}Implementation:
go
func (r *mutationResolver) resolver_ActivateUser(
ctx context.Context,
userId string,
) (model.User, error) {
tx := ctx.Value("tx").(*gorm.DB)
var user model.User
err := tx.First(&user, "id = ?", userId).Error
if err != nil {
return model.User{}, err
}
user.IsActive = true
err = tx.Save(&user).Error
return user, err
}Create with Validation
Schema:
graphql
extend type Mutation {
registerUser(name: String!, email: String!, password: String!): User!
@resolver(type: "CUSTOM")
}Implementation:
go
import (
"context"
"errors"
"gql/graph/exceptions"
"gql/graph/model"
"github.com/99designs/gqlgen/graphql"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func (r *mutationResolver) resolver_RegisterUser(
ctx context.Context,
name string,
email string,
password string,
) (model.User, error) {
tx := ctx.Value("tx").(*gorm.DB)
// Validate email not already registered
var count int64
tx.Model(&model.User{}).Where("email = ?", email).Count(&count)
if count > 0 {
err := errors.New("email already registered")
dbe := exceptions.NewDatabaseException(err, err.Error(), graphql.GetPath(ctx), "")
graphql.AddError(ctx, dbe)
return model.User{}, nil
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return model.User{}, err
}
// Create user
user := model.User{
ID: email,
Name: name,
Email: email,
Password: string(hashedPassword),
}
err = tx.Create(&user).Error
if err != nil {
dbe := exceptions.NewDatabaseException(err, err.Error(), graphql.GetPath(ctx), "")
graphql.AddError(ctx, dbe)
return model.User{}, nil
}
return user, nil
}Batch Operations
Schema:
graphql
extend type Mutation {
createUsers(users: [UserInput!]!): [User]! @resolver(type: "CUSTOM")
}
input UserInput {
name: String!
email: String!
}Implementation using repository batch create:
go
func (r *mutationResolver) resolver_CreateUsers(
ctx context.Context,
users []*model.UserInput,
) ([]*model.User, error) {
tx := ctx.Value("tx").(*gorm.DB)
var modelUsers []*model.User
for _, input := range users {
modelUsers = append(modelUsers, &model.User{
ID: input.Email,
Name: input.Name,
Email: input.Email,
})
}
err := r.Repo.UserRepository.CreateBatch(
&modelUsers,
100, // batch size
&repository.GormOptions{Tx: tx},
)
return modelUsers, err
}Subscription Resolvers
Subscriptions enable real-time updates via WebSocket connections. The GraphQL handler is configured with WebSocket transport:
go
// From gql.go:
h.AddTransport(&transport.Websocket{
KeepAlivePingInterval: 15 * time.Second,
})Basic Subscription
Schema:
graphql
extend type Subscription {
userUpdated(userId: ID): User! @subscriber(type: "CUSTOM")
}Generated stub (subscriptions return a channel):
go
// From custom_resolver.go.tpl - subscription variant:
func (r *subscriptionResolver) resolver_UserUpdated(
ctx context.Context,
userId *string,
) (<-chan model.User, error) {
ch := make(chan model.User)
defer close(ch)
return ch, errors.New("method not implemented")
}Implementation:
go
func (r *subscriptionResolver) resolver_UserUpdated(
ctx context.Context,
userId *string,
) (<-chan model.User, error) {
ch := make(chan model.User, 1)
go func() {
defer close(ch)
for {
select {
case <-ctx.Done():
return
case event := <-eventChannel:
if userId == nil || *userId == event.UserID {
ch <- event.User
}
}
}
}()
return ch, nil
}Error Handling
How the Error Pipeline Works
The DDK's error handling chain works as follows:
- PanicHandler (
handlers/errorhandler.go): Catches panics in resolvers, logs with stack trace, returns generic "internal server error" - ErrorPresenter (
handlers/errorhandler.go): Intercepts all GraphQL errors:- If the error is a
DatabaseException→ rolls back the transaction to"preTransaction"savepoint and returns structured error with trace - Otherwise → delegates to
graphql.DefaultErrorPresenter
- If the error is a
Using DatabaseException
For database-related errors, use the DatabaseException type from graph/exceptions/:
go
import (
"gql/graph/exceptions"
"github.com/99designs/gqlgen/graphql"
)
if err != nil {
dbe := exceptions.NewDatabaseException(
err, // Original error
"user-friendly message", // Message shown to client
graphql.GetPath(ctx), // GraphQL path for error location
"", // Optional additional context
)
graphql.AddError(ctx, dbe)
dbe.Log(ctx) // Logs with the per-request Zap logger
return model.User{}, nil // Return zero value (error already added)
}Important: When a DatabaseException is raised, the ErrorPresenter automatically calls tc.RollbackTo("preTransaction") on the transaction, ensuring partial writes are rolled back.
Standard Error Return
For non-database errors, simply return an error:
go
if len(email) == 0 {
return model.User{}, errors.New("email is required")
}This gets wrapped by graphql.DefaultErrorPresenter and returned as a standard GraphQL error.
How Resolver Preservation Works
The DDK uses multiple mechanisms to preserve custom code across regenerations:
1. CustomResolverPlugin Skip Logic
The CustomResolverPlugin only generates stub files for new custom operations. It checks os.Stat() on the target file path — if the file exists, it's skipped entirely:
go
customFile := fmt.Sprintf("./graph/resolver/%s.custom.resolvers.go", strings.ToLower(resolver.GoFieldName))
_, err := os.Stat(customFile)
if err == nil {
continue // Don't overwrite existing implementations
}2. ResolverPlugin Rewriter
The standard ResolverPlugin uses gqlgen's rewriter module to preserve existing resolver implementations. When regenerating {type}.resolvers.go files, it:
- Reads the existing file
- Extracts implemented resolver function bodies
- Generates new file with updated signatures
- Re-inserts the preserved function bodies
3. WorkspaceMigrate File Copying
The WorkspaceMigrate function in workspace_content.go copies files bidirectionally:
From workspace → server (before generation):
- Calls
FindCustomResolverFiles()to locate*.custom.resolvers.gofiles - Copies them into the server's
graph/resolver/directory
From server → workspace (after generation):
- Copies all generated files back to workspace
4. Safe File Patterns
These file patterns are never overwritten by the generator:
| Pattern | Location | Contains |
|---|---|---|
*.custom.resolvers.go | graph/resolver/ | Custom resolver implementations |
*.custom.go | graph/model/ | Custom model extensions |
*.custom.repository.go | infrastructure/repository/ | Custom repository methods |
Generated Test Scaffolding
For each custom resolver, the CustomResolverPlugin generates a test file from custom_resolver_test.go.tpl:
test/{name}.custom.resolvers_test.goEach test file contains two test functions:
Mock Test
go
func TestMock_GetUserByEmail(t *testing.T) {
// mockClient allows mocking of repository methods
// mockClient := NewMockClient(t)
// To mock repository methods:
// mockRepo := mockClient.GetMockRepo()
// mockRepo.ExampleModel.EXPECT().ExampleRepoMethod().<stubs>
t.Run("Example test", func(t *testing.T) {
// mockClient.GQLRequest(`query { ... }`, &resp)
// require.Equal(t, expected, resp.Field)
})
}Embedded DB Test
go
func TestEmbeddedDB_GetUserByEmail(t *testing.T) {
// testClient := setup()
// testClient.ExecuteScript("path/to/your/sql/script.sql")
// testClient.GetResolver.DB.Exec("INSERT INTO ...")
}Best Practices
1. Use Transactions
Always get the transaction from context:
go
tx := ctx.Value("tx").(*gorm.DB)This ensures:
- Consistency with generated resolvers
- Proper transaction management with savepoint rollback
- Automatic rollback on
DatabaseException
2. Prefer Repository Methods Over Raw GORM
Use the typed repositories when possible — they provide:
- Order-by validation (prevents SQL injection through field name reflection check)
- Consistent error handling (
HandleError/HandleOneError) - Default joins for relationships
- Consistent query options via
GormOptions
go
// Preferred: Uses typed repository with validated ordering
err := r.Repo.UserRepository.GetByFieldBatch(
&users, "role", "admin", 10, 0,
&repository.GormOptions{Tx: tx, OrderBy: []repository.OrderBy{{Field: "name"}}},
)
// Also fine: Direct GORM queries for complex cases
err := tx.Where("age > ? AND role = ?", 18, "admin").Find(&users).Error3. Error Handling
Use DatabaseException for database errors (triggers transaction rollback):
go
if err != nil {
dbe := exceptions.NewDatabaseException(err, "user-friendly message", graphql.GetPath(ctx), "")
graphql.AddError(ctx, dbe)
return model.User{}, nil
}4. Preload Relationships
Avoid N+1 queries by preloading:
go
// Direct GORM preloading
err := tx.Preload("Posts").Preload("Comments").Find(&users).Error
// Or via repository options
opts := &repository.GormOptions{
Tx: tx,
Preloads: []string{"Posts", "Comments"},
}
err := r.Repo.UserRepository.GetAll(&users, opts)5. Validate Input
Validate before database operations:
go
if len(email) == 0 {
return model.User{}, errors.New("email required")
}
if !isValidEmail(email) {
return model.User{}, errors.New("invalid email format")
}6. One Resolver Per File
Custom resolvers follow a one-file-per-operation pattern:
graph/resolver/
├── getuserbyemail.custom.resolvers.go
├── registeruser.custom.resolvers.go
├── changepassword.custom.resolvers.go7. Document Complex Logic
Add comments explaining business rules:
go
// registerUser creates a new user account with the following validations:
// 1. Email must be unique
// 2. Password is hashed using bcrypt
// 3. Default role is set to "user"
// 4. Welcome email is sent (async)
func (r *mutationResolver) resolver_RegisterUser(
// ...8. Use the Redis Client
The resolver has access to a Redis client for caching:
go
func (r *queryResolver) resolver_GetCachedData(ctx context.Context, key string) (string, error) {
val, err := r.Redis.Get(key).Result()
if err == redis.Nil {
// Cache miss — fetch from DB and cache
// ...
r.Redis.Set(key, data, 5*time.Minute)
return data, nil
}
return val, err
}Common Patterns
Authentication Check
go
func (r *queryResolver) resolver_GetCurrentUser(
ctx context.Context,
) (model.User, error) {
// Get claims from JWT context (set by JwtMiddleware)
claims, ok := ctx.Value("jwt-claims").(jwt.MapClaims)
if !ok {
return model.User{}, errors.New("unauthorized")
}
userID, _ := claims["sub"].(string)
if userID == "" {
return model.User{}, errors.New("unauthorized")
}
tx := ctx.Value("tx").(*gorm.DB)
var user model.User
err := r.Repo.UserRepository.GetOneByID(&user, userID, &repository.GormOptions{Tx: tx})
return user, err
}Authorization Check
go
func (r *mutationResolver) resolver_DeleteUser(
ctx context.Context,
userId string,
) (bool, error) {
claims := ctx.Value("jwt-claims").(jwt.MapClaims)
currentUserID := claims["sub"].(string)
currentUserRole := claims["role"].(string)
// Only admins or the user themselves can delete
if currentUserRole != "ADMIN" && currentUserID != userId {
return false, errors.New("forbidden")
}
tx := ctx.Value("tx").(*gorm.DB)
user := &model.User{ID: userId}
err := r.Repo.UserRepository.Delete(user, &repository.GormOptions{Tx: tx})
return err == nil, err
}Pagination
go
func (r *queryResolver) resolver_ListUsersPaginated(
ctx context.Context,
page int,
pageSize int,
) ([]*model.User, error) {
tx := ctx.Value("tx").(*gorm.DB)
offset := (page - 1) * pageSize
var users []*model.User
err := r.Repo.UserRepository.GetBatch(
&users, pageSize, offset,
&repository.GormOptions{Tx: tx},
)
return users, err
}Aggregation
go
type UserStats struct {
TotalUsers int64
ActiveUsers int64
AverageAge float64
}
func (r *queryResolver) resolver_GetUserStats(
ctx context.Context,
) (*UserStats, error) {
tx := ctx.Value("tx").(*gorm.DB)
var stats UserStats
tx.Model(&model.User{}).Count(&stats.TotalUsers)
tx.Model(&model.User{}).Where(`"isActive" = ?`, true).Count(&stats.ActiveUsers)
tx.Model(&model.User{}).Select("AVG(age)").Row().Scan(&stats.AverageAge)
return &stats, nil
}Multi-Field Filter with Repository
go
func (r *queryResolver) resolver_SearchUsers(
ctx context.Context,
roles []string,
minAge *int,
) ([]*model.User, error) {
tx := ctx.Value("tx").(*gorm.DB)
filters := map[string]interface{}{
"role": roles, // Slice values use IN query automatically
}
var users []*model.User
err := r.Repo.UserRepository.GetByFields(
&users, filters,
&repository.GormOptions{Tx: tx},
)
// Additional filtering if needed
if minAge != nil {
filtered := make([]*model.User, 0)
for _, u := range users {
if u.Age >= *minAge {
filtered = append(filtered, u)
}
}
return filtered, nil
}
return users, err
}Testing
Unit Testing with Mocks
Use the generated mock repository for unit tests:
go
func TestRegisterUser(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mock_repository.NewMockUserRepository(ctrl)
mockRepo.EXPECT().
Create(gomock.Any(), gomock.Any()).
Return(nil)
// Create resolver with mock
r := &mutationResolver{
Resolver: &Resolver{
Repo: repository.RepositoryHolder{
UserRepository: mockRepo,
},
},
}
tx := db.Begin()
ctx := context.WithValue(context.Background(), "tx", tx)
user, err := r.resolver_RegisterUser(ctx, "John", "john@example.com", "pass123")
assert.NoError(t, err)
assert.Equal(t, "John", user.Name)
tx.Rollback()
}Integration Testing
Test via GraphQL API using the generated mock client:
go
func TestRegisterUserAPI(t *testing.T) {
mockClient := NewMockClient(t)
var resp struct {
RegisterUser struct {
ID string
Name string
Email string
}
}
mockClient.GQLRequest(
`mutation {
registerUser(
name: "John Doe"
email: "john@example.com"
password: "password123"
) {
id
name
email
}
}`,
&resp,
)
require.Equal(t, "John Doe", resp.RegisterUser.Name)
}Testing with GraphQL Playground
Manual testing in the built-in playground at http://localhost:{port}/:
graphql
mutation TestRegister {
registerUser(
name: "Test User"
email: "test@example.com"
password: "test123"
) {
id
name
email
}
}Troubleshooting
Common Issues
Resolver Not Found: Ensure function name matches pattern resolver_{OperationName}
Type Mismatch: Check return types match schema exactly
Transaction Errors: Always get transaction from context with ctx.Value("tx").(*gorm.DB)
Nil Pointer: Check for optional parameters with *Type, and check context values before type assertion
Preload Failures: Verify relationship mappings are correct in schema @mapping directives
Repository "not found" errors: Save and Delete return errors when RowsAffected == 0 — ensure the record exists
Order-by validation errors: The repository validates sort field names against model struct fields using reflection — ensure field names match Go struct field names (case-insensitive)
Related Documentation
- Schema Guide - Writing schemas
- Architecture - Understanding generated code
- Examples - More examples
- FAQs - Common questions
