117 Creating Custom Directives in gqlgen
117 Creating Custom Directives in gqlgen
If you have spent any meaningful time working with GraphQL in the Go ecosystem, chances are you are already familiar with gqlgen. This library has become the de facto standard for building GraphQL servers in Go. Its explicit and strongly typed nature gives it an edge when it comes to maintainable and debuggable code. There is, however, one feature that often goes underused: Custom Directives.
In this 117th article, I will walk you through building custom directives in gqlgen. We will not only cover the code, but also the technical reasoning behind it, the request flow, a simulation, and several best practices I have picked up while implementing this feature across a number of production systems.
1. Why Custom Directives Matter
A directive in GraphQL is essentially an annotation that we can attach to the schema or a query to give specific instructions while a resolver runs. Some examples of built-in directives are @skip and @include. Often, though, we need specific business logic—such as authorization, logging, input validation, or data masking. This is exactly where custom directives come into play.
2. Directive Execution Flow in gqlgen
Before diving into the code, let’s first understand how gqlgen executes a directive. The flow looks roughly like this:
flowchart TD
A[Client Request] --> B(GraphQL Server Parse Query)
B --> C{Ada Directive?}
C -- Ya --> D[Eksekusi Logic Directive]
D --> E[Eksekusi Resolver]
C -- Tidak --> E
E --> F[Return Response ke Client]
Notes:
- Every field/type in the schema that uses a directive is processed by the directive’s middleware function before the main resolver is executed.
- The logic inside a directive can either halt the request or let it continue.
3. Case Study: Building an @auth(role: "ADMIN") Directive
Problem
Suppose you want a certain field to be accessible only to users with the ADMIN role. We want a schema like this:
1type Query {
2 users: [User!]! @auth(role: "ADMIN")
3}
4
5type User {
6 id: ID!
7 name: String!
8}4. Defining the Directive in the Schema
First, we modify schema.graphql:
1directive @auth(
2 role: String!
3) on FIELD_DEFINITION5. Generating the Directive Code
After changing the schema, run:
1go run github.com/99designs/gqlgen generateThis adds a stub to generated.go and leaves us to provide the implementation in resolver.go or another module.
6. Implementing the Directive Logic
Prepare the User Context
Authentication usually stores user information in context.Context. For example, in an HTTP middleware:
1type User struct {
2 ID string
3 Role string
4}
5
6func AuthMiddleware(next http.Handler) http.Handler {
7 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
8 // Simulation: the user is already logged in as either "USER" or "ADMIN"
9 user := &User{ID: "1", Role: r.Header.Get("X-Role")}
10 ctx := context.WithValue(r.Context(), "user", user)
11 next.ServeHTTP(w, r.WithContext(ctx))
12 })
13}Implementing the @auth Directive
Open resolver.go and locate (or create):
1func (r *directiveResolver) Auth(ctx context.Context, obj interface{}, next graphql.Resolver, role string) (res interface{}, err error) {
2 userVal := ctx.Value("user")
3 user, ok := userVal.(*User)
4 if !ok || user == nil {
5 return nil, fmt.Errorf("unauthenticated")
6 }
7
8 if user.Role != role {
9 return nil, fmt.Errorf("unauthorized: need role %s", role)
10 }
11
12 // Continue to the next resolver
13 return next(ctx)
14}In short, the code above:
- Retrieves the user from the context.
- If the user is not logged in or the role does not match, returns an error.
- If the check passes, calls
next(ctx)so the resolver runs.
7. Simulation and Testing
Let’s create a simple HTTP test. The GraphQL endpoint is wrapped with the AuthMiddleware.
Example HTTP Request
1POST /graphql
2Headers:
3 X-Role: USER
4Body:
5 {
6 "query": "{ users { id name } }"
7 }1POST /graphql
2Headers:
3 X-Role: ADMIN
4Body:
5 {
6 "query": "{ users { id name } }"
7 }8. Execution Flow Table
| HTTP Header | Directive Trigger | Result | Notes |
|---|---|---|---|
| X-Role: USER | @auth(“ADMIN”) | Error | Rejected, role does not match |
| X-Role: | @auth(“ADMIN”) | Error | Rejected, not logged in |
| X-Role: ADMIN | @auth(“ADMIN”) | Success | Passes, resolver is executed |
| - | None | Success | Resolver is executed normally |
9. Best Practices for Custom Directives in gqlgen
- Isolate the Logic: Keep the directive logic in its own package (
internal/directive/) so it is easy to unit test. - Reusable: Use the context and type assertions; avoid global variables.
- Error Handling: Return a custom error type so it is easy to distinguish on the frontend.
- Composable: You can chain more than one directive, and the order in the schema matters.
10. Multi-Directive Simulation
Suppose you want a field protected by both @auth and @log.
1type Query {
2 sensitiveInfo: String! @auth(role: "ADMIN") @log
3}gqlgen executes from left to right: @auth first, then @log, and finally the main resolver.
11. Closing & Reflection
With custom directives in gqlgen, you can move common logic such as authorization, auditing, and more into the schema layer. This clarifies the separation of concerns and makes the schema truly self-documenting.
Implementing it is not difficult, but the key to success in production lies in consistency, testing, and a robust context design. The implementation above is just a simple example—you can certainly extend it for the needs of your organization, such as dynamic permissions, rate limiting, or automatic data masking.
Happy experimenting with custom directives in gqlgen—may your GraphQL architecture grow ever more robust and maintainable!
References:
Critiques, suggestions, and discussion are very welcome in the comments. See you in the next article!