gqlgen の公式 Recipes に “Optimizing N+1 database queries using Dataloaders” というページがあります。中で紹介される loader が vikstrous/dataloadgen に切り替わっているのに、検索上位の日本語記事は graph-gophers/dataloader 時代のままですよね。
v0.17 系(執筆時 v0.17.90)で生成コードの形が変わり、resolver の書き方も追従しています。古いサンプルをコピペすると generated.go が再生成されたタイミングで差分が消える構造。スキーマファースト設計の境界と、dataloadgen 連携の構成を実装ベースで整理します。
gqlgenが分けるgenerated.goとresolver.goの境界
gqlgen は schema-first のコードジェネレータです。schema.graphqls を書いて go generate を叩くと、ランタイムと型は generated.go に、ビジネスロジックの差し込み口は resolver.go に分かれて出力されます。
再生成されるファイルと手書きファイル
境界を間違えると差分が消えます。役割を整理します。
| ファイル | 性質 | 編集可否 |
|---|---|---|
graph/generated.go | スキーマから自動生成。実行ランタイム・引数のバインディング・エラーハンドリングが入る | 編集禁止。再生成で上書き |
graph/model/models_gen.go | スキーマ型からの構造体定義。autobind で外部 model に差し替え可能 | 編集禁止。再生成で上書き |
graph/resolver.go | root resolver の type Resolver struct{} 定義。DI 用フィールドの置き場 | 手書き。再生成では上書きされない |
graph/schema.resolvers.go | 各 Query/Mutation の本体実装。新しい field を schema に足すと自動追記される | 手書き。既存メソッドは保持 |
resolver.goにDIを差し込む設計
resolver は再生成でも上書きされないため、DB クライアントや service レイヤーをここに集約します。
// graph/resolver.go
package graph
import (
"database/sql"
"example.com/api/internal/loader"
)
type Resolver struct {
DB *sql.DB
Loaders *loader.Loaders
}
$ go run github.com/99designs/gqlgen generate
# generated.go と schema.resolvers.go は更新されるが
# resolver.go の DB / Loaders フィールドは保持される
スキーマ1ファイルからサーバーが立ち上がるまで
新規プロジェクトで動くまでの最小構成を見ます。gqlgen init で雛形が出力されますが、付随する tools.go と gqlgen.yml の役割は把握しておく必要があります。
初期化と最小スキーマ
go mod init example.com/api
go get -tool github.com/99designs/gqlgen@latest
go run github.com/99designs/gqlgen init
初期化で graph/schema.graphqls, gqlgen.yml, server.go, tools.go が出力されます。スキーマを書き換えてみます。
# graph/schema.graphqls
type User {
id: ID!
name: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
authorID: ID!
}
type Query {
posts: [Post!]!
}
go run github.com/99designs/gqlgen generate
go run ./server.go
# => 2026/05/08 12:00:00 connect to http://localhost:8080/ for GraphQL playground
Playground を開いて { posts { id title } } を投げると JSON が返ります。ただし posts の中で author をネストすると挙動が変わります。
resolverのシグネチャで詰まるポインタとスライス
生成された resolver は gqlgen.yml の設定でシグネチャが変わります。デフォルト設定だと [Post!]! が []*Post に展開され、[Post] なら []*Post に nil が混じる可能性が出ます。スキーマの NonNull 表現とスライス要素のポインタ可否は別物。混同するとコンパイラは通ってもランタイムで落ちます。
2つの設定キーで挙動を切り替える
# gqlgen.yml
resolvers_always_return_pointers: true
omit_slice_element_pointers: false
resolvers_always_return_pointers: true(デフォルト)では、resolver の戻り値が常に *Post や *[]*Post になります。プロジェクトで pointer を嫌うなら false にすると Post や []Post が返り値になりますが、resolver の途中で nil を返したいケースで詰まります。
典型的なコンパイルエラー
// 誤: 戻り値の形がスキーマと一致していない
func (r *queryResolver) Posts(ctx context.Context) ([]Post, error) {
return r.svc.ListPosts(ctx) // []*Post を返している
}
./schema.resolvers.go:42:9: cannot use posts (variable of type []*model.Post)
as type []model.Post in return statement
このエラーが出たら、まず gqlgen.yml のスライス・ポインタ設定とスキーマの ! を見直すのが早道です。
dataloadgenでN+1を潰す
posts 各要素から author をフェッチするクエリが入ると、Post resolver の Author field が件数分ループで走ります。10件で 11 クエリ、100件で 101 クエリ。この件数比例のクエリ発行が GraphQL の N+1 です。
dataloadgenを公式が推奨に切り替えた背景
v0.17 系で gqlgen の Recipes が graph-gophers/dataloader から vikstrous/dataloadgen に書き換わりました。dataloadgen は Go 1.18 以降のジェネリクスを活用し、型アサーションが不要です。any でラップしないので、batch 関数の戻り値の型がそのまま使えます。
go get github.com/vikstrous/dataloadgen
middlewareでcontextにloaderを注入する
// internal/loader/loader.go
package loader
import (
"context"
"net/http"
"time"
"example.com/api/graph/model"
"github.com/vikstrous/dataloadgen"
)
type ctxKey int
const loadersKey ctxKey = 0
type Loaders struct {
UserByID *dataloadgen.Loader[string, *model.User]
}
func Middleware(repo UserRepo, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
loaders := &Loaders{
UserByID: dataloadgen.NewLoader(
func(ctx context.Context, ids []string) ([]*model.User, []error) {
return repo.UsersByIDs(ctx, ids) // WHERE id IN (?)
},
dataloadgen.WithWait(2*time.Millisecond),
),
}
ctx := context.WithValue(r.Context(), loadersKey, loaders)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func For(ctx context.Context) *Loaders {
return ctx.Value(loadersKey).(*Loaders)
}
resolverからLoadを呼ぶだけ
// graph/schema.resolvers.go
func (r *postResolver) Author(ctx context.Context, obj *model.Post) (*model.User, error) {
return loader.For(ctx).UserByID.Load(ctx, obj.AuthorID)
}
処理の流れは次の通り。Load を呼ぶたびに ID がバッファに溜まり、WithWait の閾値(ここでは 2ms)が経過するか、すべての resolver の同期処理が止まったタイミングで一括フェッチされます。
$ curl -s -X POST http://localhost:8080/query \
-H 'Content-Type: application/json' \
-d '{"query":"{ posts { id title author { name } } }"}'
# 実行されるSQL(ログ抜粋)
SELECT id, title, author_id FROM posts;
SELECT id, name FROM users WHERE id IN ('u01','u02','u03','u04','u05');
# => 100件取得しても2クエリで完了
graph-gophers版との違い
古い記事との切り分けのため、両者の違いを並べます。
| 項目 | graph-gophers/dataloader(旧) | vikstrous/dataloadgen(v0.17推奨) |
|---|---|---|
| 型表現 | dataloader.Result + 型アサーション | ジェネリクスで Loader[K, V] |
| batch関数の戻り値 | []*dataloader.Result | ([]V, []error) |
| キャッシュ | 内蔵LRU | リクエスト単位の単純Map |
| 依存 | Go 1.13+ | Go 1.18+ (generics) |
graph-gophers 版は今も動きますが、新規実装で採用する理由は薄くなっています。
@hasRoleディレクティブで認可を埋め込む
認可は middleware でも書けますが、field 単位の制御はディレクティブが向きます。スキーマに権限要件を載せるとフロントエンドにも要求が伝わるのが利点です。
schemaで宣言する
directive @hasRole(role: Role!) on FIELD_DEFINITION
enum Role {
ADMIN
USER
}
type Mutation {
deletePost(id: ID!): Boolean! @hasRole(role: ADMIN)
}
Configに実装を登録する
// server.go
c := generated.Config{Resolvers: &graph.Resolver{DB: db}}
c.Directives.HasRole = func(
ctx context.Context,
obj interface{},
next graphql.Resolver,
role model.Role,
) (interface{}, error) {
u, ok := auth.UserFor(ctx)
if !ok {
return nil, fmt.Errorf("unauthenticated")
}
if u.Role != role {
return nil, fmt.Errorf("forbidden: requires %s", role)
}
return next(ctx)
}
# 一般ユーザーで叩く
$ curl -s -X POST http://localhost:8080/query -H 'Authorization: Bearer USER_TOKEN' \
-d '{"query":"mutation { deletePost(id:\"p1\") }"}'
{"errors":[{"message":"forbidden: requires ADMIN","path":["deletePost"]}],"data":null}
ミドルウェアでパス単位に書くより、スキーマ側で「この field は ADMIN だけ」と宣言できる差は記述量に効きます。複数 field に同じ要件があるときの行数が減り、フロントエンドの自動生成にも権限情報が乗ります。
gqlgen.ymlの設定キー早見
v0.17 系で初期化したときの主要キーをまとめます。生成挙動を変えたいときに見る場所。
| キー | 役割 | 典型値 |
|---|---|---|
schema | スキーマファイルの glob | graph/*.graphqls |
exec | generated.go の出力先 | graph/generated.go |
model | 自動生成 model の出力先 | graph/model/models_gen.go |
resolver | resolver スケルトンの出力先 | graph/ |
autobind | 既存型を model に紐付け | example.com/api/internal/db |
resolvers_always_return_pointers | resolver 戻り値を pointer にするか | true |
omit_slice_element_pointers | スライス要素のポインタを外す | false |
struct_tag | 生成 model に付与する struct tag | json |
まとめ
- gqlgen は
generated.goとresolver.goの境界を理解しないと再生成で差分が消える - v0.17 公式推奨の dataloader は
vikstrous/dataloadgen。ジェネリクス対応で型アサーション不要 - N+1 は middleware で context に loader を注入し、resolver から
Loadを呼ぶだけで一括フェッチに集約できる - field 単位の認可はミドルウェアより
@hasRoleディレクティブが宣言的で読みやすい - resolver のシグネチャエラーは
resolvers_always_return_pointersとomit_slice_element_pointersを疑う
GORM の N+1 は SQL 側の対処(Preload など)が中心ですが、GraphQL ではリクエスト境界の loader でまとめるのが基本になります。GORM 編は GORMのN+1問題とマイグレーション設計 にまとめてあるので、ORM 側の打ち手と合わせて見ると違いが分かります。

