Skip to content

Custom Resolvers

Platform Users — Engineers & Low-code Ops Users (ORA / Panel Builder) OR Platform ORA — AI Planning Interface Agent Workflows Plan Visualisation ADK Integration SDK UI — Frontend Shell FDK Architecture Low code Config-driven DDK Schema Definition Code Generator Generated Server MDK WEM DAL Experiment Manager Nexus Deployment Control Live Monitoring Registry Browser SCDK Source Control Pipeline Mgmt Azure DevOps deploys ↓ SDK API — GraphQL Federation Gateway Federation Gateway Component Resolvers Auth & Licensing Plugins: gql-autogeneration Migrator Helm KinD Boilerplate GenAI ··· Microservices — Domain IP Services Data Pipeline Core Platform Metrics & Analytics Spatial & Geo Simulation Event Detection Camera & Device Fire & Resource Opt. Satellite Modelling ↓ Nexus deploys Deployed OR Applications Rail Ops Dashboard Mine Mgmt Dashboard Port Ops Dashboard ··· FDK-built · DDK-backed · MDK-powered · deployed via Nexus ↑ Application Users — Operations Teams (shift managers, analysts, planners)

This guide covers how to extend DDK-generated servers with custom business logic using custom resolvers.

Table of Contents


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 type to 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.graphqls files 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 stub

Step 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:

  1. Update .custom.graphqls
  2. Run ReGenerateServer
  3. Existing implementations are preserved — the plugin skips files that already exist
  4. 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 KeyTypeSourceDescription
"tx"*gorm.DBTXMiddlewareActive database transaction with "preTransaction" savepoint
"jwt"stringJwtMiddlewareRaw JWT token from Authorization header
"jwt-claims"jwt.MapClaimsJwtMiddlewareParsed JWT claims map (unverified)
"logger"*zap.LoggerZapMiddlewarePer-request Zap structured logger with trace ID
"resolverTrace"stringZapMiddleware32-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

MethodDescription
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:

  1. PanicHandler (handlers/errorhandler.go): Catches panics in resolvers, logs with stack trace, returns generic "internal server error"
  2. ErrorPresenter (handlers/errorhandler.go): Intercepts all GraphQL errors:
    • If the error is a DatabaseExceptionrolls back the transaction to "preTransaction" savepoint and returns structured error with trace
    • Otherwise → delegates to graphql.DefaultErrorPresenter

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:

  1. Reads the existing file
  2. Extracts implemented resolver function bodies
  3. Generates new file with updated signatures
  4. 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.go files
  • 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:

PatternLocationContains
*.custom.resolvers.gograph/resolver/Custom resolver implementations
*.custom.gograph/model/Custom model extensions
*.custom.repository.goinfrastructure/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.go

Each 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).Error

3. 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.go

7. 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)


User documentation for Optimal Reality