Skip to content
Santekno.com | Tech Tutorials and Trends
EN
📖 0%
21 Oct 2025 · 6 min read ·Article 113 / 125
Go

113 Custom Validation in gqlgen Resolvers

IH
Ihsan Arif
Writer at Santekno · Backend Engineer

113 Custom Validation in gqlgen Resolvers

When building a GraphQL API in Golang, one of the go-to packages is gqlgen. This framework is powerful when it comes to composing schemas, building resolvers, and even auto-generating code based on the schema you design. But there is one classic problem: data validation.

As an engineer who has used gqlgen several times, I’m aware that data validation—especially custom validation—often ends up being a “gray area.” Plenty of people rely solely on validation at the upper layers (for example, when receiving the request), or only validate right before an operation hits the database. In reality, placing validation inside the resolver itself can be a more solid solution in terms of both performance and architecture.

In this article, I’ll break down 113 custom validations in gqlgen resolvers: the what, the how, and the best practices. I’ll round it out with code examples, simulations, tables, and diagrams so you fully understand the concept and can apply it directly in your own project.


Why Do You Need Custom Validation in the Resolver?

Before getting to the “how,” let’s first figure out the “why.” The questions I usually ask when designing an API are:

  • Does the validation belong in the schema (using a directive)?
  • Is the validation better kept at the model/database level only?
  • What about complex validation (cross-field, multi-model)?

If the answer matches one of the conditions below, custom validation in the resolver is the answer:

No.Validation Requirement ConditionIdeal Solution
1Specific validation that can’t be handled by a directive/schema aloneCustom code in the resolver
2Validation that involves querying other data (cross-table/model validation)Resolver + repo/service
3Validation that depends on multiple fields within a single objectResolver (cross-field logic)
4Validation that produces a special error (custom error message, status code)Resolver with error handling
5On-demand validation, only in certain mutations/queriesLocal to the relevant resolver

So, validation in the resolver = flexible + powerful + custom error handling.


Foundation: Resolvers in gqlgen

Before talking about custom validation, a quick refresher on resolvers in gqlgen.

graphql
 1# schema.graphqls
 2type Mutation {
 3  registerUser(input: RegisterUserInput!): UserResponse!
 4}
 5
 6input RegisterUserInput {
 7  username: String!
 8  email: String!
 9  password: String!
10}

Then in Go:

go
1// resolver.go
2func (r *mutationResolver) RegisterUser(ctx context.Context, input model.RegisterUserInput) (*model.UserResponse, error) {
3  // Implementation goes here, including custom validation
4}

The Basic Pattern: Custom Validation in the Resolver

The custom validation pattern in a resolver is quite simple:

  1. Take the input from the resolver function’s arguments
  2. Run all validation rules (using a helper, struct tags, or manual conditions)
  3. Return an error if it’s invalid

Example:

go
 1func (r *mutationResolver) RegisterUser(ctx context.Context, input model.RegisterUserInput) (*model.UserResponse, error) {
 2  if len(input.Username) < 5 {
 3    return nil, errors.New("username must be at least 5 characters")
 4  }
 5  if !strings.Contains(input.Email, "@") {
 6    return nil, errors.New("invalid email format")
 7  }
 8  if len(input.Password) < 8 {
 9    return nil, errors.New("password must be at least 8 characters")
10  }
11  
12  // Other validations...
13
14  // Process user registration and return UserResponse
15}

Simulating 113 Custom Validations

I’ll simulate 10 validation categories (with 113 rules in total) that you’ll frequently encounter:

No.CategoryExample RuleShort Description
1LengthMin/max charactersUsername min 5, password min 8
2Email formatValid email regexEmail must be valid
3DB uniquenessEmail/username already existsQuery DB before insert
4Strong password checkMix of digits, upper/lowercaseStrong password
5Cross-field rulePassword confirmation matchpassword == confirmPassword
6Date/time validationValid date of birthNot a future date
7Enum validationGender = [“male”, “female”, “other”]Validate a choice
8Nested object validationValidate fields in a complex objectComplete address
9Custom business rulesMinimum age, active user statusAge > 17
10Custom error typeError code, field error mappingField-specific error message

Implementation simulation for 3 rules: format, DB uniqueness, password confirmation

go
 1func (r *mutationResolver) RegisterUser(ctx context.Context, input model.RegisterUserInput) (*model.UserResponse, error) {
 2  // 1. Valid email
 3  if !validateEmail(input.Email) {
 4    return nil, fmt.Errorf("Email '%s' is invalid", input.Email)
 5  }
 6
 7  // 2. Unique in the DB
 8  exists, _ := r.UserRepo.IsEmailExist(ctx, input.Email)
 9  if exists {
10    return nil, errors.New("Email is already in use")
11  }
12
13  // 3. Password confirmation
14  if input.Password != input.ConfirmPassword {
15    return nil, errors.New("Password confirmation does not match")
16  }
17
18  // ... continue with the normal logic
19}
20
21func validateEmail(email string) bool {
22    re := regexp.MustCompile(`^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$`)
23    return re.MatchString(email)
24}

Best Practice: More Modular Validation

Most engineers get overwhelmed once there are more than 5 validations per endpoint. To keep things clean, use the modular validation pattern.

For example: create a validator/ package with helpers.

go
 1package validator
 2
 3import (
 4    "regexp"
 5    "errors"
 6)
 7
 8func ValidateRegisterUserInput(input *model.RegisterUserInput) error {
 9    if len(input.Username) < 5 {
10      return errors.New("username must be at least 5 characters")
11    }
12    if !validateEmail(input.Email) {
13      return errors.New("invalid email format")
14    }
15    // ... and so on
16    return nil
17}

In the resolver:

go
1if err := validator.ValidateRegisterUserInput(&input); err != nil {
2  return nil, err
3}

Mapping Errors to Fields (User Experience)

By default, GraphQL errors are thrown as a single array. However, the UX is much better if field errors are returned per field.

Schema:

graphql
 1type UserResponse {
 2  success: Boolean!
 3  user: User
 4  errors: [FieldError!]
 5}
 6
 7type FieldError {
 8  field: String!
 9  message: String!
10}

Resolver:

go
 1func (r *mutationResolver) RegisterUser(ctx context.Context, input model.RegisterUserInput) (*model.UserResponse, error) {
 2  var errors []*model.FieldError
 3
 4  if len(input.Username) < 5 {
 5    errors = append(errors, &model.FieldError{Field: "username", Message: "username must be at least 5 characters"})
 6  }
 7  if !validateEmail(input.Email) {
 8    errors = append(errors, &model.FieldError{Field: "email", Message: "invalid email format"})
 9  }
10
11  if len(errors) > 0 {
12    return &model.UserResponse{Success: false, Errors: errors}, nil
13  }
14
15  // continue the process...
16}

Validation Flow Diagram in the Resolver (Mermaid)

Sometimes a picture sticks better. Here’s the custom validation flow in a gqlgen resolver.

MERMAID
flowchart TD
    A[Receive GraphQL Mutation] --> B[Parse Input]
    B --> C[Custom Validation di Resolver]
    C -->|Valid| D[Proses Data (DB, Service, dsb)]
    C -->|Invalid| E[Return Error/FieldError]
    D --> F[Return Response (User data, Success)]
    E --> F

Conclusion

Custom validation in gqlgen resolvers isn’t really just a feature—it’s one of the keys to maintaining data integrity in our application. With 113 rules (or even more), you can arrange your validation logic step by step, from the simplest to the most complex—including returning UX-friendly errors.

My advice:

  • Don’t overload your resolver; use modular helpers.
  • Use field error mapping so consumers can easily understand where things went wrong.
  • Validation in the resolver is the mid-layer that protects your data—don’t rely solely on schema validation or DB constraints.

Give it a try, and may your gqlgen API become more robust and maintainable!

Related Articles

💬 Comments