gqldomainresolver

package module
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 5, 2026 License: MIT Imports: 13 Imported by: 0

README

gqldomainresolver

CI Go Reference

A gqlgen plugin that splits resolvers into per-domain Go packages, so domain code no longer imports graph/generated.

Requires Go 1.26+. Licensed under MIT.

Why

Standard gqlgen puts every resolver in one package that imports graph/generated. On large schemas every edit invalidates a build artifact that can reach gigabytes — incremental compilation grinds to a halt.

How

The plugin produces a two-tier layout:

  • Tier 1 — root resolver package. Thin glue: Mutation() / Query() / Subscription() constructors, wrapper structs, embeds of the per-domain mixins. Methods reach callers via Go method promotion.
  • Tier 2 — per-domain packages. One package per subdirectory of graph/schema/. Real business logic lives here. These packages never import graph/generated — gqlgen interfaces are satisfied structurally.

A domain is the parent directory name of a .graphqls file: graph/schema/todos/todo.graphqls → domain todos. Files placed directly under graph/schema/ have no domain and stay in the root package.

Quick start (new project)

1. Install
go get github.com/prusov/gqldomainresolver
2. Custom gqlgen entry point

gqlgen's default go run github.com/99designs/gqlgen cannot load plugins:

// cmd/gqlgen/main.go
package main

import (
    "log"

    "github.com/99designs/gqlgen/api"
    "github.com/99designs/gqlgen/codegen/config"
    "github.com/prusov/gqldomainresolver"
)

func main() {
    cfg, err := config.LoadConfig("gqlgen.yml")
    if err != nil {
        log.Fatal(err)
    }
    plugin, err := gqldomainresolver.New()
    if err != nil {
        log.Fatal(err)
    }
    if err := api.Generate(cfg, api.AddPlugin(plugin)); err != nil {
        log.Fatal(err)
    }
}

New() with no options migrates every domain in the schema. New domains added later are picked up automatically.

3. Configure gqlgen.yml

The plugin ships its own safety-net resolver template and injects it into gqlgen automatically — no resolver_template entry is required, and the build no longer depends on go mod vendor or copying files out of the module cache.

# gqlgen.yml
resolver:
  layout: follow-schema
  dir: graph/resolver
  package: resolver

Setting resolver_template explicitly is still honoured if you need a custom template; the plugin yields to your override.

4. Write the root Resolver struct once

The plugin does not generate graph/resolver/resolver.go. Create it:

package resolver

type Resolver struct {
    DomainMutationResolvers
    DomainQueryResolvers
    DomainSubscriptionResolvers
}

Drop any embed whose root type your schema doesn't define.

5. Generate and fill in resolver bodies
go run ./cmd/gqlgen

Each domain gets graph/resolver/<domain>/*.resolvers.go with panic stubs. Replace each panic(...) with the real implementation — bodies are preserved across regeneration via AST extraction.

Migrating an existing project

Big-bang migration is impractical for any non-trivial codebase. The plugin supports incremental migration via WithEnabledDomains — wire the plugin in as a no-op first, then move one domain per PR. For projects that want the greenfield default minus a handful of large or in-flight domains, pair New() with WithExcludedDomains("...").

See MIGRATION.md for the full playbook.

Reference

Limitations

  • A given resolver field belongs to exactly one domain — splitting one root field across multiple domain packages isn't supported.
  • Only one plugin per gqlgen run can implement ResolverImplementer — don't combine with another plugin that hooks the same interface.
  • Two raw directory names that normalize to the same Go package (e.g. order-flow and order_flow) fail at codegen with a clear collision error. Rename one or pass WithKeywordPrefix to disambiguate.

Documentation

Overview

Package gqldomainresolver is a gqlgen plugin that splits the generated resolver package into per-domain Go packages.

The standard gqlgen layout puts every resolver method in a single graph/resolver package, which forces every file to import graph/generated. In large schemas this collapses the build cache: any edit to a resolver invalidates the whole generated artifact (often hundreds of MB) and rebuilds slow to a crawl. gqldomainresolver keeps the original generated package intact for wiring and moves the resolver bodies out into per-domain packages that have no dependency on graph/generated. The per-domain packages satisfy the gqlgen interfaces by Go duck typing (matching method names and signatures), and the root resolver wires them together via Go method promotion.

Domain extraction

A domain is the parent directory name of a .graphqls schema file, e.g.

graph/schema/todos/todo.graphqls           → domain "todos"
graph/schema/business-process/x.graphqls   → domain "business-process"
graph/schema/schema.graphqls               → no domain (stays in root)

The directory name is normalized to a Go package identifier using the strip-only lowercase rule (see normalizeDomain). Names that collide with a Go keyword, equal "schema", or start with a digit get a configurable prefix prepended (default "gql", override with WithKeywordPrefix).

Greenfield vs. migration

With no options (New alone) every domain in the schema is migrated. This is the greenfield default.

To migrate an existing project incrementally, pass WithEnabledDomains with the subset of domains to move first. Calling WithEnabledDomains() with no arguments produces an explicit empty allowlist — the plugin becomes a no-op, useful as a bootstrap step that wires the plugin into a project without producing any diff.

Wiring

Construct the plugin and pass it to api.Generate alongside the standard gqlgen plugins. The plugin must run after resolvergen because it relies on resolvergen's prevImpl handling for first-time migrations.

plugin, err := gqldomainresolver.New(gqldomainresolver.WithEnabledDomains("todos"))
if err != nil {
    log.Fatal(err)
}
if err := api.Generate(cfg, api.AddPlugin(plugin)); err != nil {
    log.Fatal(err)
}

The plugin injects its own safety-net resolver template via gqlgen's ConfigMutator hook, so non-migrated fields get a panic stub without any resolver_template entry in gqlgen.yml. Setting resolver_template explicitly is still honoured and overrides the bundled template.

See the package README for a full integration walkthrough.

Index

Examples

Constants

View Source
const DefaultKeywordPrefix = "gql"

DefaultKeywordPrefix is the prefix used by normalizeDomain when a domain name collides with a Go keyword, equals "schema", or starts with a digit.

Variables

This section is empty.

Functions

This section is empty.

Types

type Option

type Option func(*Plugin)

Option configures a Plugin at construction time.

func WithEnabledDomains

func WithEnabledDomains(domains ...string) Option

WithEnabledDomains restricts migration to the listed schema-directory names. Names are matched against the *raw* directory name (e.g. "business-process", not the normalized "businessprocess") and are case-sensitive. Duplicates are deduplicated; empty entries are dropped.

Names that don't correspond to any directory in the schema cause codegen to fail with a clear error — typos and case mismatches can't silently degrade to a no-op. This is checked at GenerateCode time, after gqlgen has loaded the schema.

Calling this option — even with no arguments — switches the plugin out of the default "all domains" mode into an explicit allowlist. WithEnabledDomains() with no arguments produces an empty allowlist, which makes the plugin a no-op (useful as a bootstrap step in an incremental migration).

Example

Restrict migration to a subset of domains. Used during incremental migration of an existing project, where domains move to Tier-2 packages one at a time. Names are matched against the *raw* schema-directory name (case-sensitive). Names that are not present in the schema cause codegen to fail with a clear error so typos can't silently degrade to a no-op.

package main

import (
	"fmt"

	"github.com/prusov/gqldomainresolver"
)

func main() {
	plugin, err := gqldomainresolver.New(
		gqldomainresolver.WithEnabledDomains("todos", "users", "business-process"),
	)
	if err != nil {
		panic(err)
	}
	fmt.Println(plugin.Name())
}
Output:
gqldomainresolver
Example (Bootstrap)

Migration bootstrap: WithEnabledDomains() with no arguments produces an explicit empty allowlist, so the plugin is a no-op. This lets a project wire the plugin into its build before migrating any domain — the first PR introduces zero diff in the existing resolvers.

package main

import (
	"fmt"

	"github.com/prusov/gqldomainresolver"
)

func main() {
	plugin, err := gqldomainresolver.New(
		gqldomainresolver.WithEnabledDomains(),
	)
	if err != nil {
		panic(err)
	}
	fmt.Println(plugin.Name())
}
Output:
gqldomainresolver

func WithExcludedDomains added in v0.2.0

func WithExcludedDomains(domains ...string) Option

WithExcludedDomains excludes the listed schema-directory names from migration. Like WithEnabledDomains, names are matched against the *raw* directory name (case-sensitive); duplicates are deduplicated and empty entries are dropped.

Names that don't correspond to any directory in the schema cause codegen to fail with a clear error — mirrors WithEnabledDomains so typos surface loudly rather than degrading to a silent no-op.

Can be combined with WithEnabledDomains: the allowlist is applied first, then the exclude list subtracts from the result. Used standalone (without WithEnabledDomains) it is the natural fit for a project that wants to migrate every domain *except* a known large or in-flight one — pair it with the greenfield default. WithExcludedDomains() with no arguments is allowed but a no-op.

func WithKeywordPrefix

func WithKeywordPrefix(prefix string) Option

WithKeywordPrefix overrides the prefix used to disambiguate domain names that collide with Go keywords, "schema", or that start with a digit. The default is DefaultKeywordPrefix ("gql"), so e.g. an "import" directory produces package "gqlimport" and "2fa" produces "gql2fa".

The prefix must be a non-empty valid Go identifier prefix: it must start with an ASCII lowercase letter and may contain only lowercase letters and digits afterwards. Invalid prefixes cause New() to return an error.

Example

Override the prefix used when a domain name collides with a Go keyword, equals "schema", or starts with a digit. The default is "gql" (DefaultKeywordPrefix), so the directory "import" produces package "gqlimport". Passing "dom" produces "domimport" instead.

package main

import (
	"fmt"

	"github.com/prusov/gqldomainresolver"
)

func main() {
	plugin, err := gqldomainresolver.New(
		gqldomainresolver.WithKeywordPrefix("dom"),
		gqldomainresolver.WithEnabledDomains("import"),
	)
	if err != nil {
		panic(err)
	}
	fmt.Println(plugin.Name())
}
Output:
gqldomainresolver

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin implements gqlgen's ResolverImplementer + CodeGenerator.

The import path for generated domain packages is derived at codegen time from the resolver section of gqlgen.yml — the plugin is module-agnostic.

By default — New() with no options — every domain in the schema is migrated. This is the greenfield configuration.

To restrict migration to a subset of domains, pass WithEnabledDomains. This is used during incremental migration of an existing project, where domains are moved to their own packages one at a time. An explicit empty allowlist (WithEnabledDomains() with no arguments) makes the plugin a no-op — useful as a bootstrap step in a migration so the plugin can be wired up without producing any diff.

To exclude specific domains from migration — typically large or experimental domains that aren't ready to move yet — pass WithExcludedDomains. It can be combined with WithEnabledDomains: the allowlist is applied first, then the exclude list subtracts from the result.

Plugin is not safe for concurrent use. Construct one instance per api.Generate() call; gqlgen runs single-threaded today, but Implement() mutates internal state and parallel codegen would race.

func New

func New(opts ...Option) (*Plugin, error)

New constructs the plugin. With no options every domain is migrated (greenfield default). Pass WithEnabledDomains to restrict migration to a specific subset — typically during incremental migration of an existing project.

Returns an error if WithKeywordPrefix was passed an invalid prefix.

Example

Greenfield default. With no options every domain in the schema is migrated to its own Tier-2 package — typically what a new project wants.

package main

import (
	"fmt"

	"github.com/prusov/gqldomainresolver"
)

func main() {
	plugin, err := gqldomainresolver.New()
	if err != nil {
		panic(err)
	}
	fmt.Println(plugin.Name())
}
Output:
gqldomainresolver

func (*Plugin) GenerateCode

func (p *Plugin) GenerateCode(data *codegen.Data) error

GenerateCode generates files in domain packages. Called by api.Generate() AFTER resolvergen.

func (*Plugin) Implement

func (p *Plugin) Implement(prevImpl string, field *codegen.Field) string

Implement is called by resolvergen for each resolver field. Returns the method body string written into .resolvers.go in the root package.

Returning "" signals the safety-net template (resolver.gotpl) to skip emitting a method declaration entirely. We rely on Go method promotion: each wrapper (mutationResolver / queryResolver / subscriptionResolver) embeds the kind-specific DomainMutationResolvers / DomainQueryResolvers / DomainSubscriptionResolvers, which embed the per-domain Mixin*Mutation/Query/ Subscription structs, whose methods are promoted up through the wrapper.

Behavior, in evaluation order:

  • field whose domain is enabled → "" (template skips; method lives in the domain package). Applies to root and non-root fields alike — non-root methods are reached via the per-object constructor returning the domain-package resolver.
  • prevImpl != "" → preserve hand-written code in the root file. Critical for the gradual migration case: a project's existing field resolvers (e.g. (r *todoResolver) User) must survive regen until their domain is enabled.
  • otherwise → panic stub.

func (*Plugin) MutateConfig added in v1.1.0

func (p *Plugin) MutateConfig(cfg *config.Config) error

MutateConfig materializes the embedded safety-net resolver template to a temp file and points cfg.Resolver.ResolverTemplate at it, so consumers do not need to set resolver_template themselves or rely on `go mod vendor` to surface the file at a stable path.

An explicit cfg.Resolver.ResolverTemplate set by the consumer is left untouched — the plugin yields to a custom template path.

The temp file is intentionally not cleaned up: codegen is a short-lived process, the plugin cannot observe the end of api.Generate, and the OS rotates /tmp on its own. Not worth complicating the lifecycle for.

func (*Plugin) Name

func (p *Plugin) Name() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL