A modern, secure GraphQL handler for Go with built-in authentication, validation, and an intuitive builder API.
- 🚀 Zero Config Start - Default hello world schema included
- 🔧 Type-Safe Resolvers - Compile-time type safety with generic resolvers
- 🎯 Type-Safe Arguments - Chainable
WithArgAPI with automatic parsing into scalars or structs - ✍️ Typed Mutations -
NewMutation[T, In]with kind-based builders (Create/Update/Delete/Action/Upsert),Patch[In]for partial updates, and lifecycle hooks - 🏗️ Fluent Builder API - Clean, intuitive schema construction
- 🔐 Built-in Auth - Automatic Bearer token extraction
- 🛡️ Security First - Query depth, complexity, and introspection protection
- 🧹 Response Sanitization - Remove field suggestions from errors
- 🎭 Middleware System - Built-in logging, auth, caching + custom middleware support
- 🔄 Real-time Subscriptions - WebSocket subscriptions with dual protocol support
- ⚡ Framework Agnostic - Works with net/http, Gin, or any framework
- ⚡ High Performance - ~30–35 μs per request, optional
QueryASTCachedrops validation cost to ~141 ns on hits
Built on top of graphql-go.
go get github.com/paulmanoni/go-graphStart immediately with a built-in hello world schema:
package main
import (
"log"
"net/http"
"github.com/paulmanoni/go-graph"
)
func main() {
// No schema needed! Includes default hello query & echo mutation
handler := graph.NewHTTP(&graph.GraphContext{
Playground: true,
DEBUG: true,
})
http.Handle("/graphql", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}Test it:
# Query
{ hello }
# Mutation
mutation { echo(message: "test") }Use the fluent builder API for clean, type-safe schema construction:
package main
import (
"log"
"net/http"
"github.com/paulmanoni/go-graph"
)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Define your queries with type-safe resolvers
func getHello() graph.QueryField {
return graph.NewResolver[string]("hello").
WithResolver(func(p graph.ResolveParams) (*string, error) {
msg := "Hello, World!"
return &msg, nil
}).BuildQuery()
}
func getUser() graph.QueryField {
return graph.NewResolver[User]("user").
WithArg("id", graph.String).
WithResolver(func(p graph.ResolveParams) (*User, error) {
id := graph.Get[string](graph.ArgsMap(p.Args), "id")
return &User{ID: id, Name: "Alice"}, nil
}).BuildQuery()
}
func main() {
handler := graph.NewHTTP(&graph.GraphContext{
SchemaParams: &graph.SchemaBuilderParams{
QueryFields: []graph.QueryField{
getHello(),
getUser(),
},
MutationFields: []graph.MutationField{},
},
Playground: true,
DEBUG: false,
})
http.Handle("/graphql", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}Bring your own graphql-go schema:
import "github.com/graphql-go/graphql"
schema, _ := graphql.NewSchema(graphql.SchemaConfig{
Query: graphql.NewObject(graphql.ObjectConfig{
Name: "Query",
Fields: graphql.Fields{
"hello": &graphql.Field{
Type: graphql.String,
Resolve: func(p graph.ResolveParams) (interface{}, error) {
return "world", nil
},
},
},
}),
})
handler := graph.NewHTTP(&graph.GraphContext{
Schema: &schema,
Playground: true,
})Token is automatically extracted from Authorization: Bearer <token> header and available in all resolvers:
handler := graph.NewHTTP(&graph.GraphContext{
SchemaParams: &graph.SchemaBuilderParams{
QueryFields: []graph.QueryField{
getProtectedQuery(),
},
},
// Optional: Fetch user details from token and update context
UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
// Validate JWT, query database, etc.
user, err := validateAndGetUser(token)
if err != nil {
return ctx, nil, err
}
// Add values to context (accessible via p.Context.Value() in resolvers)
ctx = context.WithValue(ctx, "userID", user.ID)
ctx = context.WithValue(ctx, "roles", user.Roles)
return ctx, user, nil
},
})Access in resolvers:
func getProtectedQuery() graph.QueryField {
return graph.NewResolver[User]("me").
WithResolver(func(p graph.ResolveParams) (*User, error) {
// Option 1: Get values from context (set by UserDetailsFn)
userID := p.Context.Value("userID").(string)
// Option 2: Get token directly using generic GetRoot
token := graph.GetRoot[string](graph.NewRootInfo(p), "token")
if token == "" {
return nil, fmt.Errorf("authentication required")
}
// Option 3: Get user details struct using generic GetRoot
user, err := graph.GetRootE[User](graph.NewRootInfo(p), "details")
if err != nil {
return nil, err
}
return &user, nil
}).BuildQuery()
}Extract tokens from cookies, custom headers, or query params:
handler := graph.NewHTTP(&graph.GraphContext{
SchemaParams: &graph.SchemaBuilderParams{...},
TokenExtractorFn: func(r *http.Request) string {
// From cookie
if cookie, err := r.Cookie("auth_token"); err == nil {
return cookie.Value
}
// From custom header
if apiKey := r.Header.Get("X-API-Key"); apiKey != "" {
return apiKey
}
// From query param
return r.URL.Query().Get("token")
},
UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
user, err := getUserByToken(token)
return ctx, user, err
},
})Enable all security features for production:
handler := graph.NewHTTP(&graph.GraphContext{
SchemaParams: &graph.SchemaBuilderParams{...},
DEBUG: false, // Enable security features
EnableValidation: true, // Validate queries
EnableSanitization: true, // Sanitize errors
Playground: false, // Disable playground
UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
user, err := validateAndGetUser(token)
if err != nil {
return ctx, nil, err
}
return ctx, user, nil
},
})For simple validation needs, use EnableValidation: true:
- Max Query Depth: 10 levels
- Max Aliases: 4 per query
- Max Complexity: 200
- Introspection: Disabled (blocks
__schemaand__type)
For advanced validation needs, see the Custom Validation Rules section below.
Removes field suggestions from error messages:
Before:
{
"errors": [{
"message": "Cannot query field \"nam\". Did you mean \"name\"?"
}]
}After:
{
"errors": [{
"message": "Cannot query field \"nam\"."
}]
}Use DEBUG: true during development to skip all validation and sanitization:
handler := graph.NewHTTP(&graph.GraphContext{
SchemaParams: &graph.SchemaBuilderParams{...},
DEBUG: true, // Disables validation & sanitization
Playground: true, // Enable playground for testing
})The package provides a powerful and flexible validation system that goes beyond simple EnableValidation: true. Create custom validation rules to enforce security policies, authentication requirements, rate limits, and more.
- 🛡️ Pre-built Security Rules - Max depth, complexity, aliases, introspection blocking, token limits
- 🔐 Authentication & Authorization - Require auth for operations, role-based and permission-based access control
- ⚡ Rate Limiting - Budget-based rate limiting with role bypasses
- 📦 Preset Collections - SecurityRules, StrictSecurityRules, DevelopmentRules
- 🎯 Type-Safe - Strongly-typed AuthContext with user information
- 🔧 Composable - Combine multiple rules and rule sets
⚠️ Multi-Error Support - Collect and return all validation errors at once- 🚀 High Performance - Adds minimal overhead (~1-2μs per query)
handler := graph.NewHTTP(&graph.GraphContext{
SchemaParams: &graph.SchemaBuilderParams{...},
// Custom validation rules (replaces EnableValidation)
ValidationRules: []graph.ValidationRule{
graph.NewMaxDepthRule(10),
graph.NewMaxComplexityRule(200),
graph.NewNoIntrospectionRule(),
graph.NewRequireAuthRule("mutation", "subscription"),
graph.NewRoleRules(map[string][]string{
"deleteUser": {"admin"},
"viewAuditLog": {"admin", "auditor"},
}),
},
// Fetch user details from token (reuses existing UserDetailsFn)
UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
user, err := validateJWT(token)
if err != nil {
return ctx, nil, err
}
return ctx, user, nil
},
})Prevents deeply nested queries that can cause performance issues:
graph.NewMaxDepthRule(10) // Max 10 levels deepBlocks:
{
level1 {
level2 {
level3 {
# ... more than 10 levels
}
}
}
}Limits query computational cost (complexity = number of fields × depth):
graph.NewMaxComplexityRule(200) // Max complexity of 200Example: Query with 50 fields at depth 5 = complexity 250 → Rejected
Prevents alias-based denial-of-service attacks:
graph.NewMaxAliasesRule(4) // Max 4 aliases per queryBlocks:
{
u1: user(id: 1) { name }
u2: user(id: 2) { name }
u3: user(id: 3) { name }
u4: user(id: 4) { name }
u5: user(id: 5) { name } # 5th alias rejected
}Blocks schema introspection in production:
graph.NewNoIntrospectionRule()Blocks:
{ __schema { types { name } } }
{ __type(name: "User") { fields { name } } }Limits total tokens in query (prevents extremely large queries):
graph.NewMaxTokensRule(500) // Max 500 tokensRequire authentication for specific operations or fields:
// Require auth for all mutations and subscriptions
graph.NewRequireAuthRule("mutation", "subscription")
// Require auth for specific fields
graph.NewRequireAuthRule("sensitiveData", "adminPanel")
// Combine both
graph.NewRequireAuthRule("mutation", "subscription", "sensitiveData")Example response when unauthenticated:
{
"errors": [{
"message": "mutation operations require authentication",
"rule": "RequireAuthRule"
}]
}Enforce role-based access control:
// Single field rule
graph.NewRoleRule("deleteUser", "admin")
// Multiple roles allowed
graph.NewRoleRule("viewReports", "admin", "manager")
// Batch configuration (preferred for multiple fields)
graph.NewRoleRules(map[string][]string{
"deleteUser": {"admin"},
"deleteAccount": {"admin"},
"viewAuditLog": {"admin", "auditor"},
"approveOrder": {"admin", "manager"},
})Minimal interface requirements:
Your user struct only needs to implement the methods required by the rules you use:
// For RoleRule and RoleRules
type HasRolesInterface interface {
HasRole(role string) bool
}
// For PermissionRule and PermissionRules
type HasPermissionsInterface interface {
HasPermission(permission string) bool
}
// For RateLimitRule
type HasIDInterface interface {
GetID() string
}
// Example user implementation
type User struct {
ID string
Roles []string
Permissions []string
}
func (u *User) GetID() string {
return u.ID
}
func (u *User) HasRole(role string) bool {
for _, r := range u.Roles {
if r == role {
return true
}
}
return false
}
func (u *User) HasPermission(perm string) bool {
for _, p := range u.Permissions {
if p == perm {
return true
}
}
return false
}Fine-grained permission-based access control:
// Single field permission
graph.NewPermissionRule("sensitiveData", "read:sensitive")
// Multiple permissions
graph.NewPermissionRule("exportData", "export:data", "admin:all")
// Batch configuration
graph.NewPermissionRules(map[string][]string{
"sensitiveData": {"read:sensitive"},
"exportData": {"export:data"},
"adminPanel": {"admin:access"},
})Block specific fields from being queried:
rule := graph.NewBlockedFieldsRule("internalUsers", "deprecatedField")
// With reasons
rule.BlockField("legacyAPI", "deprecated: use v2 API")
rule.BlockField("internalData", "not available in this version")Budget-based rate limiting with role bypasses:
graph.NewRateLimitRule(
graph.WithBudgetFunc(getBudgetFromRedis),
graph.WithCostPerUnit(2), // Multiply complexity by 2
graph.WithBypassRoles("admin", "service"),
)Budget function examples:
// Simple fixed budget
graph.SimpleBudgetFunc(1000)
// Per-user budgets
graph.PerUserBudgetFunc(map[string]int{
"premium_user": 10000,
"basic_user": 1000,
}, 500) // default budget
// Redis-based sliding window
func getRedisBudget(userID string) (int, error) {
key := fmt.Sprintf("rate_limit:%s", userID)
remaining, err := redis.Get(ctx, key).Int()
if err == redis.Nil {
return 1000, nil // Reset budget
}
return remaining, err
}Pre-configured rule sets for common scenarios:
Standard security for production:
graph.ValidationRules: graph.SecurityRules- Max depth: 10
- Max complexity: 200
- Max aliases: 4
- Introspection: Blocked
Stricter limits for high-security environments:
graph.ValidationRules: graph.StrictSecurityRules- Max depth: 8
- Max complexity: 150
- Max aliases: 3
- Max tokens: 500
- Introspection: Blocked
Lenient rules for development:
graph.ValidationRules: graph.DevelopmentRules- Max depth: 20
- Max complexity: 500
- No other restrictions
Merge multiple rule sets:
rules := graph.CombineRules(
graph.SecurityRules,
[]graph.ValidationRule{
graph.NewRequireAuthRule("mutation"),
graph.NewRoleRules(graph.AdminOnlyFields),
},
)
handler := graph.NewHTTP(&graph.GraphContext{
ValidationRules: rules,
})Pre-built role configurations for common access patterns:
// Use built-in configurations
graph.NewRoleRules(graph.AdminOnlyFields)
graph.NewRoleRules(graph.ManagerFields)
graph.NewRoleRules(graph.AuditorFields)
// Merge multiple configurations
allRoles := graph.MergeRoleConfigs(
graph.AdminOnlyFields,
graph.ManagerFields,
graph.AuditorFields,
)
graph.NewRoleRules(allRoles)Built-in role configs:
AdminOnlyFields: deleteUser, deleteAccount, viewAuditLog, systemSettings, manageRolesManagerFields: approveOrder, viewReports, manageTeam, bulkOperationsAuditorFields: viewAuditLog, exportLogs, viewAnalytics
Complete production setup with security, auth, and rate limiting:
package main
import (
"net/http"
graph "github.com/paulmanoni/go-graph"
)
func main() {
handler := graph.NewHTTP(&graph.GraphContext{
SchemaParams: &graph.SchemaBuilderParams{...},
// Strict security validation
ValidationRules: graph.CombineRules(
graph.StrictSecurityRules,
[]graph.ValidationRule{
// Authentication
graph.NewRequireAuthRule("mutation", "subscription"),
// Role-based access
graph.NewRoleRules(map[string][]string{
"deleteUser": {"admin"},
"viewAuditLog": {"admin", "auditor"},
"approveOrder": {"admin", "manager"},
}),
// Permission-based access
graph.NewPermissionRules(map[string][]string{
"sensitiveData": {"read:sensitive"},
"exportData": {"export:data"},
}),
// Rate limiting
graph.NewRateLimitRule(
graph.WithBudgetFunc(getRedisBudget),
graph.WithCostPerUnit(2),
graph.WithBypassRoles("admin", "service"),
),
},
),
// Fetch user details from JWT token (reuses existing UserDetailsFn)
UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
user, err := validateJWT(token)
if err != nil {
return ctx, nil, err // Invalid token
}
return ctx, user, nil // Returns your user struct that implements minimal interfaces
},
// Validation options
ValidationOptions: &graph.ValidationOptions{
StopOnFirstError: false, // Collect all errors
SkipInDebug: true, // Skip in DEBUG mode
},
// Other production settings
EnableSanitization: true,
Playground: false,
DEBUG: false,
})
http.Handle("/graphql", handler)
http.ListenAndServe(":8080", nil)
}Create custom rules by embedding BaseRule:
type IPWhitelistRule struct {
graph.BaseRule
allowedIPs []string
}
func NewIPWhitelistRule(ips ...string) graph.ValidationRule {
return &IPWhitelistRule{
BaseRule: graph.NewBaseRule("IPWhitelistRule"),
allowedIPs: ips,
}
}
func (r *IPWhitelistRule) Validate(ctx *graph.ValidationContext) error {
// Access request from context if needed
// clientIP := getClientIP(ctx.Request)
// Check IP whitelist
// if !contains(r.allowedIPs, clientIP) {
// return r.NewError("IP not whitelisted")
// }
return nil
}BaseRule provides:
NewError(msg string) *ValidationErrorNewErrorf(format string, args ...interface{}) *ValidationErrorEnable()/Disable()/Enabled() bool- Consistent error formatting
{
"errors": [{
"message": "query depth 12 exceeds maximum 10",
"rule": "MaxDepthRule"
}]
}When StopOnFirstError: false:
{
"errors": [
{
"message": "query depth 12 exceeds maximum 10",
"rule": "MaxDepthRule"
},
{
"message": "field 'deleteUser' requires one of roles: [admin]",
"rule": "RoleRules"
},
{
"message": "query cost 250 exceeds available budget 200",
"rule": "RateLimitRule"
}
]
}Configure validation behavior:
&graph.ValidationOptions{
// Collect all errors vs stop on first
StopOnFirstError: false,
// Skip validation in DEBUG mode
SkipInDebug: true,
// Optional: cache parsed ASTs across requests so repeat queries skip the parser.
// Sized to an upper bound on the number of distinct queries you expect.
// When full, the cache is reset wholesale to keep memory bounded.
QueryCache: graph.NewQueryASTCache(256),
}Validation adds minimal overhead (measured on Apple M1 Pro):
| Rule | Time/op | Allocations | Description |
|---|---|---|---|
| MaxDepthRule | ~1.4 μs | 43 allocs | Query depth validation |
| MaxComplexityRule | ~1.4 μs | 43 allocs | Complexity calculation |
| MaxAliasesRule | ~1.6 μs | 51 allocs | Alias counting |
| NoIntrospectionRule | ~1.1 μs | 36 allocs | Introspection blocking |
| RequireAuthRule | ~1.0 μs | 30 allocs | Authentication check |
| RoleRule | ~626 ns | 20 allocs | Role validation |
| PermissionRule | ~603 ns | 20 allocs | Permission check |
| RateLimitRule | ~1.2 μs | 36 allocs | Budget + complexity |
| SecurityRules (preset) | ~1.4 μs | 43 allocs | Depth + complexity + aliases + introspection |
| SecurityRules (cached) | ~141 ns | 1 alloc | Same preset via QueryASTCache |
| StrictSecurityRules | ~1.2 μs | 36 allocs | Stricter limits |
| Combined rules | ~965 ns | 29 allocs | Multiple custom rules |
For a complete HTTP request:
- Debug mode (no validation): ~31 μs
- With validation: ~35 μs
- With sanitization: ~35 μs (41 allocs in the sanitize path itself)
- With auth: ~30 μs
- Overhead: ~1–2 μs for validation on a cache miss, ~100 ns on a cache hit
Before (simple validation):
handler := graph.NewHTTP(&graph.GraphContext{
EnableValidation: true, // Deprecated
})After (custom rules):
handler := graph.NewHTTP(&graph.GraphContext{
ValidationRules: graph.SecurityRules, // Same defaults
})Or use presets:
// Development
ValidationRules: graph.DevelopmentValidationRules()
// Production
ValidationRules: graph.ProductionValidationRules()
// Custom
ValidationRules: graph.DefaultValidationRules()- Start with presets: Use
SecurityRulesorStrictSecurityRulesas a base - Add auth rules: Layer in
RequireAuthRuleandRoleRulesas needed - Test in development: Use
DevelopmentRuleswithDEBUG: truefor development - Collect all errors: Set
StopOnFirstError: falsefor better debugging - Monitor performance: Validation overhead is minimal (~3-5μs total)
- Use UserDetailsFn: Centralize auth logic by implementing UserDetailsFn instead of checking tokens in each resolver
- Implement minimal interfaces: Only implement the interfaces needed by your validation rules (HasRolesInterface, HasPermissionsInterface, HasIDInterface)
Use the type-safe generic functions to access root values:
// Get string with zero value on error
token := graph.GetRoot[string](graph.NewRootInfo(p), "token")
// Get string with error handling
token, err := graph.GetRootE[string](graph.NewRootInfo(p), "token")
if err != nil {
return nil, fmt.Errorf("authentication required")
}
// Get string with default value
token := graph.GetRootOr[string](graph.NewRootInfo(p), "token", "anonymous")
// Get struct with type safety
user := graph.GetRoot[User](graph.NewRootInfo(p), "details")
// Get struct with error handling
user, err := graph.GetRootE[User](graph.NewRootInfo(p), "details")
if err != nil {
return nil, err
}
// MustGet - panics if not found (use when certain value exists)
user := graph.MustGetRoot[User](graph.NewRootInfo(p), "details")| Function | Missing Key | Conversion Error | Use Case |
|---|---|---|---|
GetRoot[T] |
Zero value | Zero value | Optional values, quick access |
GetRootOr[T] |
Default | Default | Values with defaults |
GetRootE[T] |
Error | Error | Required values with validation |
MustGetRoot[T] |
Panic | Panic | Required values (certain to exist) |
The original pointer-based functions are still available:
// Get token (legacy)
token, err := graph.GetRootString(p, "token")
// Get user details (legacy)
var user User
err := graph.GetRootInfo(p, "details", &user)The package automatically generates GraphQL types from your Go structs, including full support for embedded structs. Fields from embedded structs are automatically flattened to the parent level.
// Define a base entity with common fields
type BaseEntity struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// Product embeds BaseEntity - fields are automatically flattened
type Product struct {
BaseEntity
Name string `json:"name"`
Price float64 `json:"price"`
}
// GraphQL type is automatically generated with ALL fields at the same level:
// type Product {
// id: String
// created_at: DateTime
// updated_at: DateTime
// name: String
// price: Float
// }
func getProduct() graph.QueryField {
return graph.NewResolver[Product]("product").
WithResolver(func(p graph.ResolveParams) (*Product, error) {
return &Product{
BaseEntity: BaseEntity{
ID: "123",
CreatedAt: time.Now(),
},
Name: "Widget",
Price: 19.99,
}, nil
}).BuildQuery()
}Embedded Struct Features:
- ✅ Automatic field flattening - No nested objects, all fields at parent level
- ✅ Multiple embedding - Embed multiple structs (e.g.,
BaseEntity+Metadata) - ✅ Nested embedding - Multi-level embedding supported (Level1 → Level2 → Level3)
- ✅ Pointer embedding - Works with
*BaseEntityas well - ✅ Field override - Child fields with same name take precedence
- ✅ Works everywhere - Supported in queries, mutations, input objects, and arguments
Performance:
- Field generation with embedded structs: ~1.2 μs
- Field resolver execution: ~232 ns
- Zero runtime overhead after type generation
The WithResolver method provides compile-time type safety by accepting a function that returns *T instead of interface{}:
// ✅ Type-safe - returns *User
graph.NewResolver[User]("user").
WithArg("id", graph.String).
WithResolver(func(p graph.ResolveParams) (*User, error) {
id := graph.Get[string](graph.ArgsMap(p.Args), "id")
user := db.GetUserByID(id) // Most ORMs return *User
return user, nil // No type assertions needed!
}).BuildQuery()
// ✅ Works with lists - returns *[]User
graph.NewResolver[User]("users").
AsList().
WithResolver(func(p graph.ResolveParams) (*[]User, error) {
users := db.ListUsers()
return &users, nil
}).BuildQuery()
// ✅ Works with primitives - returns *string
graph.NewResolver[string]("message").
WithResolver(func(p graph.ResolveParams) (*string, error) {
msg := "Hello!"
return &msg, nil
}).BuildQuery()Benefits:
- ✅ No type assertions or casts needed
- ✅ Compiler catches type mismatches at build time
- ✅ Better IDE autocomplete and refactoring
- ✅ Cleaner, more readable code
- ✅ Works with pointers (can return
nilfor not found)
type Post struct {
ID int `json:"id"`
Title string `json:"title"`
AuthorID int `json:"authorId"`
}
// Type-safe query
func getPost() graph.QueryField {
return graph.NewResolver[Post]("post").
WithArg("id", graph.Int).
WithResolver(func(p graph.ResolveParams) (*Post, error) {
id := graph.Get[int](graph.ArgsMap(p.Args), "id")
post, err := postService.GetByID(id)
if err != nil {
return nil, err
}
// Return *Post directly - no type assertions!
return post, nil
}).BuildQuery()
}
// Type-safe list query
func getPosts() graph.QueryField {
return graph.NewResolver[Post]("posts").
AsList().
WithResolver(func(p graph.ResolveParams) (*[]Post, error) {
posts, err := postService.List()
if err != nil {
return nil, err
}
return &posts, nil
}).BuildQuery()
}
// Type-safe mutation
func createPost() graph.MutationField {
type CreatePostInput struct {
Title string `json:"title"`
AuthorID int `json:"authorId"`
}
return graph.NewResolver[Post]("createPost").
WithInputObject(CreatePostInput{}).
WithResolver(func(p graph.ResolveParams) (*Post, error) {
var input CreatePostInput
if err := graph.GetArg(p, "input", &input); err != nil {
return nil, err
}
return postService.Create(input.Title, input.AuthorID)
}).BuildMutation()
}WithArg provides a fluent API for adding arguments to your resolvers. It supports scalars, structs (including deeply nested), and uses ArgsMap(p.Args) for type-safe argument access.
// Without args - simple resolver
graph.NewResolver[Message]("hello").
WithResolver(func(p graph.ResolveParams) (*Message, error) {
return &Message{Text: "Hello, World!"}, nil
}).BuildQuery()
// With scalar args - using graph.String, graph.Int, etc.
graph.NewResolver[User]("user").
WithArg("id", graph.String).
WithArg("limit", graph.Int).
WithResolver(func(p graph.ResolveParams) (*User, error) {
id := graph.Get[string](graph.ArgsMap(p.Args), "id")
limit := graph.GetOr[int](graph.ArgsMap(p.Args), "limit", 10)
return userService.GetByID(id)
}).BuildQuery()
// With struct input - auto-generates InputObject
type UserInput struct {
Name string `json:"name"`
Email string `json:"email"`
}
graph.NewResolver[User]("createUser").
WithArg("input", UserInput{}).
WithResolver(func(p graph.ResolveParams) (*User, error) {
input := graph.Get[UserInput](graph.ArgsMap(p.Args), "input")
return userService.Create(input.Name, input.Email)
}).BuildMutation()| Type | Usage | GraphQL Type |
|---|---|---|
graph.String |
WithArg("id", graph.String) |
String |
graph.Int |
WithArg("age", graph.Int) |
Int |
graph.Float |
WithArg("price", graph.Float) |
Float |
graph.Boolean |
WithArg("active", graph.Boolean) |
Boolean |
graph.ID |
WithArg("userId", graph.ID) |
ID |
| Zero values | WithArg("name", "") |
String |
| Structs | WithArg("input", UserInput{}) |
UserInputInput |
// Get a value with type safety (returns zero value if missing)
id := graph.Get[string](args, "id")
age := graph.Get[int](args, "age")
active := graph.Get[bool](args, "active")
// Get struct argument with automatic conversion
input := graph.Get[UserInput](args, "input")
// Get with default value
limit := graph.GetOr[int](args, "limit", 10)
name := graph.GetOr[string](args, "name", "Anonymous")
// Get with error handling (returns error if missing or conversion fails)
input, err := graph.GetE[UserInput](args, "input")
if err != nil {
return nil, fmt.Errorf("invalid input: %w", err)
}
// MustGet panics if missing or conversion fails (use when certain arg exists)
id := graph.MustGet[string](args, "id")
// Check if argument exists
if args.Has("optionalField") {
// process optional field
}
// Get raw map for complex processing (if needed)
rawArgs := args.Raw()When using subscriptions or custom resolvers that receive p.Args directly (a map[string]interface{}), you can use graph.ArgsMap to wrap it and use the same type-safe getter functions:
// In subscriptions or custom resolvers with graphql.FieldConfigArgument
subscription := graph.NewSubscription[Message]("onMessage").
WithField("channelID", graphql.String).
WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *Message, error) {
// Wrap p.Args with ArgsMap to use type-safe getters
channelID := graph.Get[string](graph.ArgsMap(p.Args), "channelID")
// Or with error handling
channelID, err := graph.GetE[string](graph.ArgsMap(p.Args), "channelID")
if err != nil {
return nil, fmt.Errorf("channelID required: %w", err)
}
// ... create and return channel
})Both graph.Args (from WithArg chainable API) and graph.ArgsMap (wrapping p.Args) implement the ArgsGetter interface, so all getter functions work with both:
| Source | Usage | When to Use |
|---|---|---|
graph.Args |
graph.Get[T](args, "key") |
With WithResolver(func(p, args)...) |
graph.ArgsMap(p.Args) |
graph.Get[T](graph.ArgsMap(p.Args), "key") |
Subscriptions, middlewares, custom resolvers |
| Function | Missing Key | Conversion Error | Use Case |
|---|---|---|---|
Get[T] |
Zero value | Zero value | Optional args, quick access |
GetOr[T] |
Default | Default | Args with defaults |
GetE[T] |
Error | Error | Required args with validation |
MustGet[T] |
Panic | Panic | Required args (certain to exist) |
Example with error handling:
graph.NewResolver[User]("createUser").
WithArg("input", CreateUserInput{}).
WithResolver(func(p graph.ResolveParams) (*User, error) {
// Explicit error handling
input, err := graph.GetE[CreateUserInput](graph.ArgsMap(p.Args), "input")
if err != nil {
return nil, fmt.Errorf("invalid input: %w", err)
}
return userService.Create(input.Name, input.Email)
}).BuildMutation()// Required argument (GraphQL NonNull)
graph.NewResolver[User]("user").
WithArgRequired("id", graph.String).
WithResolver(func(p graph.ResolveParams) (*User, error) {
id := graph.Get[string](graph.ArgsMap(p.Args), "id") // always present
return userService.GetByID(id)
}).BuildQuery()
// Argument with default value
graph.NewResolver[User]("users").
AsList().
WithArgDefault("limit", graph.Int, 20).
WithArgDefault("offset", graph.Int, 0).
WithResolver(func(p graph.ResolveParams) (*[]User, error) {
limit := graph.Get[int](graph.ArgsMap(p.Args), "limit") // 20 if not provided
offset := graph.Get[int](graph.ArgsMap(p.Args), "offset") // 0 if not provided
return userService.List(limit, offset)
}).BuildQuery()Deeply nested structs are automatically converted to GraphQL InputObjects:
type AddressInput struct {
Street string `json:"street"`
City string `json:"city"`
Country string `json:"country"`
}
type UserProfileInput struct {
Name string `json:"name"`
Age int `json:"age"`
Address AddressInput `json:"address"`
}
// Generates nested InputObjects automatically
graph.NewResolver[User]("createUser").
WithArg("profile", UserProfileInput{}).
WithResolver(func(p graph.ResolveParams) (*User, error) {
profile := graph.Get[UserProfileInput](graph.ArgsMap(p.Args), "profile")
// Access nested data with type safety
return userService.Create(profile.Name, profile.Address.City)
}).BuildMutation()WithResolver accepts a single, simple signature:
func(p graph.ResolveParams) (*T, error)Access arguments using ArgsMap(p.Args) with the type-safe Get functions:
// Without args
graph.NewResolver[Message]("hello").
WithResolver(func(p graph.ResolveParams) (*Message, error) {
return &Message{Text: "Hello!"}, nil
}).BuildQuery()
// With args - access via ArgsMap(p.Args)
graph.NewResolver[User]("user").
WithArg("id", graph.String).
WithResolver(func(p graph.ResolveParams) (*User, error) {
id := graph.Get[string](graph.ArgsMap(p.Args), "id")
return userService.GetByID(id)
}).BuildQuery()type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type CreateUserInput struct {
Name string `json:"name"`
Email string `json:"email"`
}
// Query with multiple args
func getUsers() graph.QueryField {
return graph.NewResolver[User]("users").
AsList().
WithArg("search", graph.String).
WithArgDefault("limit", graph.Int, 20).
WithArgDefault("offset", graph.Int, 0).
WithResolver(func(p graph.ResolveParams) (*[]User, error) {
search := graph.GetOr[string](graph.ArgsMap(p.Args), "search", "")
limit := graph.Get[int](graph.ArgsMap(p.Args), "limit")
offset := graph.Get[int](graph.ArgsMap(p.Args), "offset")
users := userService.Search(search, limit, offset)
return &users, nil
}).BuildQuery()
}
// Mutation with struct input
func createUser() graph.MutationField {
return graph.NewResolver[User]("createUser").
WithArg("input", CreateUserInput{}).
WithResolver(func(p graph.ResolveParams) (*User, error) {
input := graph.Get[CreateUserInput](graph.ArgsMap(p.Args), "input")
return userService.Create(input.Name, input.Email)
}).BuildMutation()
}NewMutation[T, In] is the recommended way to build mutations. It forces you to pick a
kind (Create, Update, Delete, Action, Upsert) before wiring a resolver — each kind
gives you the right resolver signature and the right schema shape. Input decoding, lifecycle
hooks, and the presence-aware Patch[In] for partial updates are built in. Decode plans and
patch field tables are cached per input type, so repeat calls avoid re-walking reflection.
The kind enforces intent at compile time:
| Kind | Resolver signature | Use case |
|---|---|---|
Create |
func(ctx, In) (*T, error) |
Insert a new record |
Update |
func(ctx, Patch[In]) (*T, error) |
Partial update — Patch tells you which fields the client sent |
Delete |
func(ctx, In) (*T, error) |
Remove a record (returns the deleted entity, or a tombstone) |
Action |
func(ctx, In) (*T, error) |
Side-effectful operation that isn't CRUD (sendEmail, rotateKey, etc.) |
Upsert |
func(ctx, Patch[In]) (Result[T], error) |
Insert-or-update — Result.Created surfaces which path ran |
The MutationBuilder itself has no WithResolver or Build; you must transition to a kind
builder first. This prevents accidentally using a Create resolver signature for an Update
that needs presence information.
type CreateUserInput struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
}
createUser := graph.NewMutation[User, CreateUserInput]("createUser").
WithDescription("Create a new user").
Create().
WithResolver(func(ctx context.Context, in CreateUserInput) (*User, error) {
return userService.Create(ctx, in)
}).
Build()
graph.NewHTTP(&graph.GraphContext{
SchemaParams: &graph.SchemaBuilderParams{
MutationFields: []graph.MutationField{createUser},
},
})The input type (CreateUserInput) is automatically registered as a GraphQL InputObject
named CreateUserInputInput (the "Input" suffix is appended when missing). The output type
(User) is registered as a GraphQL Object. Both are cached globally so the same type
produces the same GraphQL type across mutations.
Update and Upsert resolvers receive Patch[In], which distinguishes omitted from set
to zero value. This is crucial for PATCH-style updates where null or missing fields should
not overwrite existing data.
type UpdateUserInput struct {
Name string `json:"name"`
Email string `json:"email"`
Bio string `json:"bio"`
}
updateUser := graph.NewMutation[User, UpdateUserInput]("updateUser").
Update().
WithResolver(func(ctx context.Context, p graph.Patch[UpdateUserInput]) (*User, error) {
existing, err := userService.Get(ctx, userIDFromCtx(ctx))
if err != nil {
return nil, err
}
// Only fields actually sent by the client are applied onto `existing`.
p.Apply(existing)
// Or inspect manually:
if p.Has("email") {
// Email was sent — check uniqueness, etc.
}
return userService.Save(ctx, existing)
}).
Build()Patch[In] exposes:
| Method | Purpose |
|---|---|
Get() In |
The decoded input struct |
Has(field string) bool |
Was this field sent by the client? |
Fields() []string |
All field names the client sent |
Presence() PresenceSet |
Read-only set view |
Apply(dst *T) |
Copy only the present fields onto dst (reflection-cached) |
Upsert surfaces the insert-vs-update outcome to the client via an auto-generated
<MutationName>Payload object with result and created fields:
upsertUser := graph.NewMutation[User, UserInput]("upsertUser").
Upsert().
WithResolver(func(ctx context.Context, p graph.Patch[UserInput]) (graph.Result[User], error) {
u, created, err := userService.Upsert(ctx, p.Get())
if err != nil {
return graph.Result[User]{}, err
}
return graph.Result[User]{Value: u, Created: created}, nil
}).
Build()GraphQL query:
mutation {
upsertUser(input: { name: "Ada", email: "ada@example.com" }) {
created
result { id name email }
}
}Implement any of these interfaces on your input type to hook into the mutation pipeline. They run in this order, all before the resolver:
| Interface | Method | Order | Failure Code |
|---|---|---|---|
InputNormalizer |
Normalize() |
1 | — |
InputAuthorizer |
Authorize(ctx) error |
2 | UNAUTHORIZED |
PatchInputValidator (Update/Upsert only) |
ValidatePatch(ctx, present) error |
3 | INVALID_INPUT |
InputValidator (Create/Delete/Action, or Update/Upsert fallback) |
Validate(ctx) error |
3 | INVALID_INPUT |
type CreateUserInput struct {
Name string `json:"name"`
Email string `json:"email"`
}
// Trim whitespace and lowercase the email before anything else sees it.
func (in *CreateUserInput) Normalize() {
in.Name = strings.TrimSpace(in.Name)
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
}
// Gate the mutation to logged-in users.
func (in *CreateUserInput) Authorize(ctx context.Context) error {
if _, ok := ctx.Value("userID").(string); !ok {
return errors.New("login required")
}
return nil
}
// Enforce field-level rules after normalization.
func (in *CreateUserInput) Validate(ctx context.Context) error {
if !strings.Contains(in.Email, "@") {
return &graph.MutationError{
Code: graph.CodeInvalidInput, Field: "email", Message: "invalid email",
}
}
return nil
}For Update, prefer ValidatePatch(ctx, present PresenceSet) error so you can skip rules for
fields the client didn't send:
func (in *UpdateUserInput) ValidatePatch(ctx context.Context, present graph.PresenceSet) error {
if present.Has("email") && !strings.Contains(in.Email, "@") {
return &graph.MutationError{Code: graph.CodeInvalidInput, Field: "email"}
}
return nil
}Return *MutationError (or any error — non-MutationError errors are wrapped as
INVALID_INPUT or UNAUTHORIZED depending on which hook failed, or INTERNAL if the
resolver itself is unset). Codes map cleanly to GraphQL error extensions:
const (
CodeInvalidInput ErrorCode = "INVALID_INPUT"
CodeUnauthorized ErrorCode = "UNAUTHORIZED"
CodeNotFound ErrorCode = "NOT_FOUND"
CodeConflict ErrorCode = "CONFLICT"
CodeInternal ErrorCode = "INTERNAL"
)MutationError implements Extensions() map[string]any, which graphql-go surfaces as the
error's extensions block — clients get a structured { "code": "CONFLICT", "field": "email" }
instead of a string they have to regex.
return nil, &graph.MutationError{
Code: graph.CodeConflict,
Field: "email",
Message: "email already registered",
}All kinds share these configuration calls on the base builder:
graph.NewMutation[User, CreateUserInput]("createUser").
WithDescription("Register a new user account").
WithInputName("payload"). // default is "input"
Use(authMiddleware, auditMiddleware).
Create().
WithResolver(createHandler).
Build()Middleware wraps the resolver using the same FieldMiddleware type as resolver middleware —
see Middleware below.
| Operation | Time/op | Allocations |
|---|---|---|
| Build (Create) | ~381 ns | 10 allocs |
| Build (Update) | ~380 ns | 10 allocs |
| Build (Action) | ~356 ns | 10 allocs |
| Execute (Create) | ~302 ns | 5 allocs |
| Execute (Update) | ~443 ns | 7 allocs |
| Execute (Delete) | ~227 ns | 5 allocs |
| Execute (Action) | ~232 ns | 5 allocs |
| Decode Input | ~245 ns | 3 allocs |
| Patch.Apply | ~93 ns | 1 alloc |
Decode plans (tag parsing + setter selection per field) and patch field tables (name + struct
index) are cached in sync.Maps keyed by reflect.Type. Reflection happens once per input
type, ever — the hot path uses the cached plan directly.
The older pattern still works, but has rough edges the new API fixes:
| Concern | NewResolver[T].BuildMutation() |
NewMutation[T, In] |
|---|---|---|
| Resolver signature | Same for create/update/delete/action | Kind-specific — compiler enforces the right shape |
| Partial updates | Manual — you decide per field whether empty means "omitted" or "clear" | First-class Patch[In] with Has(field) |
| Upsert payload | DIY — return a map or custom struct | Built-in Result[T] + auto-generated <Name>Payload |
| Lifecycle hooks | Scattered across middleware | Interface-based: Normalize, Authorize, Validate(Patch) |
| Error codes | String messages | Typed MutationError with Extensions() |
| Input parsing | GetArg(p, "input", &in) (re-parse per call) |
Cached decode plan per type |
The old BuildMutation() is still supported for backwards compatibility; new code should
prefer NewMutation[T, In].
The library provides a powerful middleware system for adding cross-cutting concerns like authentication, logging, caching, and more to your resolvers.
Apply middleware to the entire resolver using WithMiddleware(). Middleware functions are applied in the order they're added (first added = outermost layer):
graph.NewResolver[User]("user").
WithArg("id", graph.Int).
WithMiddleware(graph.LoggingMiddleware).
WithMiddleware(graph.AuthMiddleware("admin")).
WithResolver(func(p graph.ResolveParams) (*User, error) {
id := graph.Get[int](graph.ArgsMap(p.Args), "id")
return userService.GetByID(id)
}).BuildQuery()Execution flow:
- LoggingMiddleware (starts timer)
- AuthMiddleware (checks permissions)
- Your resolver (executes business logic)
- AuthMiddleware (returns)
- LoggingMiddleware (logs duration)
Logs resolver execution time to stdout:
graph.NewResolver[Post]("post").
WithMiddleware(graph.LoggingMiddleware).
WithResolver(func(p graph.ResolveParams) (*Post, error) {
return postService.GetByID(id)
}).BuildQuery()
// Output: Field post resolved in 2.5msRequires a specific user role from context:
graph.NewResolver[User]("adminUser").
WithMiddleware(graph.AuthMiddleware("admin")).
WithResolver(func(p graph.ResolveParams) (*User, error) {
return userService.GetAdmin()
}).BuildQuery()Caches resolver results based on a custom key function:
graph.NewResolver[Product]("product").
WithArg("id", graph.Int).
WithMiddleware(graph.CacheMiddleware(func(p graph.ResolveParams) string {
id := graph.Get[int](graph.ArgsMap(p.Args), "id")
return fmt.Sprintf("product:%d", id)
})).
WithResolver(func(p graph.ResolveParams) (*Product, error) {
// Only executes on cache miss
id := graph.Get[int](graph.ArgsMap(p.Args), "id")
return productService.GetByID(id)
}).BuildQuery()Create custom middleware by implementing the FieldMiddleware function signature:
// FieldMiddleware wraps a resolver with additional functionality
type FieldMiddleware func(next FieldResolveFn) FieldResolveFn
// Example: Rate limiting middleware
func RateLimitMiddleware(limit int) graph.FieldMiddleware {
requests := make(map[string]int)
var mu sync.Mutex
return func(next graph.FieldResolveFn) graph.FieldResolveFn {
return func(p graph.ResolveParams) (interface{}, error) {
// Extract user ID from context
userID := p.Context.Value("userID").(string)
mu.Lock()
if requests[userID] >= limit {
mu.Unlock()
return nil, fmt.Errorf("rate limit exceeded")
}
requests[userID]++
mu.Unlock()
return next(p)
}
}
}
// Usage
graph.NewResolver[User]("user").
WithMiddleware(RateLimitMiddleware(100)).
WithResolver(func(p graph.ResolveParams) (*User, error) {
return userService.GetByID(id)
}).BuildQuery()Chain multiple middleware for complex behavior:
graph.NewResolver[User]("user").
WithMiddleware(graph.LoggingMiddleware). // 1. Log timing
WithMiddleware(graph.AuthMiddleware("user")). // 2. Check auth
WithMiddleware(RateLimitMiddleware(100)). // 3. Rate limit
WithMiddleware(graph.CacheMiddleware(cacheKeyFn)). // 4. Cache results
WithResolver(func(p graph.ResolveParams) (*User, error) {
return userService.GetByID(id)
}).BuildQuery()Apply middleware to specific fields within a type:
graph.NewResolver[User]("users").
AsList().
WithFieldMiddleware("email", graph.AuthMiddleware("admin")).
WithFieldMiddleware("salary", graph.AuthMiddleware("hr")).
WithResolver(func(p graph.ResolveParams) (*[]User, error) {
return userService.List(), nil
}).BuildQuery()WithPermission() is a convenience wrapper around WithMiddleware() for authorization:
graph.NewResolver[User]("deleteUser").
AsMutation().
WithArg("id", graph.Int).
WithPermission(graph.AuthMiddleware("admin")).
WithResolver(func(p graph.ResolveParams) (*User, error) {
id := graph.Get[int](graph.ArgsMap(p.Args), "id")
return userService.Delete(id)
}).BuildMutation()func InjectDependencies(db *gorm.DB, cache *redis.Client) graph.FieldMiddleware {
return func(next graph.FieldResolveFn) graph.FieldResolveFn {
return func(p graph.ResolveParams) (interface{}, error) {
ctx := p.Context
ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "cache", cache)
// Create new params with injected context
newParams := p
newParams.Context = ctx
return next(newParams)
}
}
}func ErrorHandlingMiddleware(next graph.FieldResolveFn) graph.FieldResolveFn {
return func(p graph.ResolveParams) (interface{}, error) {
result, err := next(p)
if err != nil {
// Log error
log.Printf("Error in %s: %v", p.Info.FieldName, err)
// Transform error for user
return nil, fmt.Errorf("an error occurred: please contact support")
}
return result, nil
}
}func MetricsMiddleware(metrics *prometheus.Registry) graph.FieldMiddleware {
return func(next graph.FieldResolveFn) graph.FieldResolveFn {
return func(p graph.ResolveParams) (interface{}, error) {
start := time.Now()
result, err := next(p)
duration := time.Since(start)
// Record metrics
fieldName := fmt.Sprintf("%s.%s", p.Info.ParentType.Name(), p.Info.FieldName)
recordMetric(metrics, fieldName, duration, err != nil)
return result, err
}
}
}- Order matters: Place authentication before caching, logging outermost
- Keep middleware pure: Avoid side effects when possible
- Use context for data: Pass request-scoped data via context
- Handle errors gracefully: Always return meaningful errors
- Measure performance: Use logging/metrics middleware to track slow resolvers
- Batch database queries: Use dataloaders to prevent N+1 queries
Real-time event streaming over WebSocket with type-safe resolvers and middleware support.
- 🔄 Real-time Events - Stream data to clients over WebSocket
- 🎯 Type-Safe Resolvers - Generic subscription builders with compile-time safety
- 🔌 Dual Protocol Support - Works with both
graphql-ws(modern) andsubscriptions-transport-ws(legacy) - 🏗️ Fluent Builder API - Same intuitive API as queries and mutations
- 🛡️ Middleware Support - Apply authentication, logging, and custom middleware
- 🔍 Event Filtering - Filter events before sending to clients
- 📦 Pluggable PubSub - In-memory, Redis, Kafka, or custom backends
- 🎭 Field-Level Control - Custom resolvers and middleware per field
package main
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/graphql-go/graphql"
graph "github.com/paulmanoni/go-graph"
)
type MessageEvent struct {
ID string `json:"id"`
Content string `json:"content"`
Author string `json:"author"`
Timestamp time.Time `json:"timestamp"`
}
func main() {
// Initialize PubSub system
pubsub := graph.NewInMemoryPubSub()
defer pubsub.Close()
// Create subscription
messageSubscription := graph.NewSubscription[MessageEvent]("messageAdded").
WithDescription("Subscribe to new messages").
WithArgs(graphql.FieldConfigArgument{
"channelID": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.String),
},
}).
WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *MessageEvent, error) {
channelID := graph.Get[string](graph.ArgsMap(p.Args), "channelID")
// Create output channel
events := make(chan *MessageEvent, 10)
// Subscribe to PubSub topic
subscription := pubsub.Subscribe(ctx, "messages:"+channelID)
// Forward events to GraphQL channel
go func() {
defer close(events)
for msg := range subscription {
var event MessageEvent
json.Unmarshal(msg.Data, &event)
events <- &event
}
}()
return events, nil
}).
BuildSubscription()
// Build GraphQL handler with subscriptions
handler := graph.NewHTTP(&graph.GraphContext{
SchemaParams: &graph.SchemaBuilderParams{
QueryFields: []graph.QueryField{...},
MutationFields: []graph.MutationField{...},
SubscriptionFields: []graph.SubscriptionField{messageSubscription},
},
PubSub: pubsub,
EnableSubscriptions: true,
Playground: true,
})
http.Handle("/graphql", handler)
http.ListenAndServe(":8080", nil)
}Test the subscription:
subscription {
messageAdded(channelID: "general") {
id
content
author
timestamp
}
}The PubSub interface enables pluggable backends for event distribution:
type PubSub interface {
Publish(ctx context.Context, topic string, data interface{}) error
Subscribe(ctx context.Context, topic string) <-chan *Message
Unsubscribe(ctx context.Context, subscriptionID string) error
Close() error
}Perfect for development and single-instance deployments:
pubsub := graph.NewInMemoryPubSub()
defer pubsub.Close()
// Publish events
ctx := context.Background()
pubsub.Publish(ctx, "messages:general", &MessageEvent{
ID: "1",
Content: "Hello!",
Author: "Alice",
})
// Subscribe to events
subscription := pubsub.Subscribe(ctx, "messages:general")
for msg := range subscription {
// Process message
}Implement the PubSub interface for Redis, Kafka, or other message brokers:
type RedisPubSub struct {
client *redis.Client
// ... implementation
}
func (r *RedisPubSub) Publish(ctx context.Context, topic string, data interface{}) error {
jsonData, _ := json.Marshal(data)
return r.client.Publish(ctx, topic, jsonData).Err()
}
func (r *RedisPubSub) Subscribe(ctx context.Context, topic string) <-chan *graph.Message {
// ... implementation
}
// Use with GraphContext
handler := graph.NewHTTP(&graph.GraphContext{
PubSub: &RedisPubSub{client: redisClient},
EnableSubscriptions: true,
})The NewSubscription[T] builder provides a fluent API for creating type-safe subscriptions:
graph.NewSubscription[EventType]("subscriptionName").
WithDescription("Description of the subscription").
WithArgs(graphql.FieldConfigArgument{...}).
WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *EventType, error) {
// Return a channel that emits events
}).
WithFilter(func(ctx context.Context, data *EventType, p graph.ResolveParams) bool {
// Filter events before sending to clients
return true
}).
WithMiddleware(graph.AuthMiddleware("user")).
WithFieldResolver("customField", func(p graph.ResolveParams) (interface{}, error) {
// Custom resolver for specific fields
}).
WithFieldMiddleware("sensitiveField", graph.AuthMiddleware("admin")).
BuildSubscription()type UserStatusEvent struct {
UserID string `json:"userID"`
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
}
func userStatusSubscription(pubsub graph.PubSub) graph.SubscriptionField {
return graph.NewSubscription[UserStatusEvent]("userStatusChanged").
WithDescription("Subscribe to user status changes").
WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *UserStatusEvent, error) {
events := make(chan *UserStatusEvent, 10)
subscription := pubsub.Subscribe(ctx, "user_status")
go func() {
defer close(events)
for msg := range subscription {
var event UserStatusEvent
json.Unmarshal(msg.Data, &event)
events <- &event
}
}()
return events, nil
}).
BuildSubscription()
}func messageSubscription(pubsub graph.PubSub) graph.SubscriptionField {
return graph.NewSubscription[Message]("messageAdded").
WithArgs(graphql.FieldConfigArgument{
"channelID": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.String),
Description: "Channel to subscribe to",
},
}).
WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *Message, error) {
channelID := graph.Get[string](graph.ArgsMap(p.Args), "channelID")
events := make(chan *Message, 10)
subscription := pubsub.Subscribe(ctx, "messages:"+channelID)
go func() {
defer close(events)
for msg := range subscription {
var message Message
json.Unmarshal(msg.Data, &message)
events <- &message
}
}()
return events, nil
}).
BuildSubscription()
}Filter events based on user permissions or subscription criteria:
func messageSubscription(pubsub graph.PubSub) graph.SubscriptionField {
return graph.NewSubscription[Message]("messageAdded").
WithArgs(graphql.FieldConfigArgument{
"channelID": &graphql.ArgumentConfig{Type: graphql.String},
}).
WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *Message, error) {
// Subscribe to all messages
events := make(chan *Message, 10)
subscription := pubsub.Subscribe(ctx, "messages")
go func() {
defer close(events)
for msg := range subscription {
var message Message
json.Unmarshal(msg.Data, &message)
events <- &message
}
}()
return events, nil
}).
WithFilter(func(ctx context.Context, data *Message, p graph.ResolveParams) bool {
// Filter by channel ID
channelID := graph.Get[string](graph.ArgsMap(p.Args), "channelID")
return data.ChannelID == channelID
}).
BuildSubscription()
}Protect subscriptions with authentication:
func adminSubscription(pubsub graph.PubSub) graph.SubscriptionField {
return graph.NewSubscription[AdminEvent]("adminEvents").
WithDescription("Admin-only event stream").
WithMiddleware(graph.AuthMiddleware("admin")).
WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *AdminEvent, error) {
// Only admins reach here
events := make(chan *AdminEvent, 10)
subscription := pubsub.Subscribe(ctx, "admin_events")
go func() {
defer close(events)
for msg := range subscription {
var event AdminEvent
json.Unmarshal(msg.Data, &event)
events <- &event
}
}()
return events, nil
}).
BuildSubscription()
}Customize how specific fields are resolved:
type MessageEvent struct {
ID string `json:"id"`
Content string `json:"content"`
AuthorID string `json:"authorID"`
}
func messageSubscription(pubsub graph.PubSub) graph.SubscriptionField {
return graph.NewSubscription[MessageEvent]("messageAdded").
WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *MessageEvent, error) {
// ... resolver implementation
}).
WithFieldResolver("author", func(p graph.ResolveParams) (interface{}, error) {
// Custom resolver for author field
event := p.Source.(MessageEvent)
return userService.GetByID(event.AuthorID), nil
}).
WithFieldMiddleware("content", graph.AuthMiddleware("user")).
BuildSubscription()
}Configure WebSocket behavior through GraphContext:
handler := graph.NewHTTP(&graph.GraphContext{
SchemaParams: &graph.SchemaBuilderParams{
SubscriptionFields: []graph.SubscriptionField{...},
},
// Enable subscriptions
EnableSubscriptions: true,
// PubSub backend
PubSub: pubsub,
// Optional: Custom WebSocket path (default: auto-detects)
WebSocketPath: "/subscriptions",
// Optional: Custom origin check
WebSocketCheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return origin == "https://example.com"
},
// Optional: Authentication for WebSocket connections
UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
user, err := validateAndGetUser(token)
return ctx, user, err
},
})WebSocket authentication works similarly to HTTP:
handler := graph.NewHTTP(&graph.GraphContext{
SchemaParams: &graph.SchemaBuilderParams{
SubscriptionFields: []graph.SubscriptionField{...},
},
EnableSubscriptions: true,
PubSub: pubsub,
// Extract user details from token
UserDetailsFn: func(ctx context.Context, token string) (context.Context, interface{}, error) {
user, err := validateJWT(token)
if err != nil {
return ctx, nil, err
}
return ctx, user, nil
},
})Client sends token during connection initialization:
const client = new SubscriptionClient('ws://localhost:8080/graphql', {
connectionParams: {
authorization: 'Bearer <token>',
},
});Access user details in subscription resolver:
func messageSubscription(pubsub graph.PubSub) graph.SubscriptionField {
return graph.NewSubscription[Message]("messageAdded").
WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *Message, error) {
// Get authenticated user
var user User
if err := graph.GetRootInfo(p, "details", &user); err != nil {
return nil, fmt.Errorf("authentication required")
}
// Subscribe to user-specific messages
events := make(chan *Message, 10)
subscription := pubsub.Subscribe(ctx, "messages:"+user.ID)
// ... forward events
return events, nil
}).
BuildSubscription()
}Trigger subscription events from mutations:
func sendMessageMutation(pubsub graph.PubSub) graph.MutationField {
type SendMessageInput struct {
ChannelID string `json:"channelID" graphql:"channelID,required"`
Content string `json:"content" graphql:"content,required"`
}
return graph.NewResolver[Message]("sendMessage").
WithInputObject(SendMessageInput{}).
WithResolver(func(p graph.ResolveParams) (*Message, error) {
var input SendMessageInput
graph.GetArg(p, "input", &input)
// Create message
msg := &Message{
ID: uuid.New().String(),
Content: input.Content,
ChannelID: input.ChannelID,
Timestamp: time.Now(),
}
// Store in database
db.Create(msg)
// Publish to subscribers
ctx := context.Background()
pubsub.Publish(ctx, "messages:"+input.ChannelID, msg)
return msg, nil
}).
BuildMutation()
}The WebSocket handler supports both modern and legacy protocols:
| Protocol | Support | Client |
|---|---|---|
graphql-ws |
✅ Full | Apollo Client 3+, urql |
subscriptions-transport-ws |
✅ Full | Apollo Client 2, GraphQL Playground |
Both protocols work simultaneously - no configuration needed.
Monitor and limit concurrent WebSocket connections:
var (
maxConnections = 10000
currentConns int64
)
handler := graph.NewHTTP(&graph.GraphContext{
EnableSubscriptions: true,
WebSocketCheckOrigin: func(r *http.Request) bool {
if atomic.LoadInt64(¤tConns) >= int64(maxConnections) {
return false
}
atomic.AddInt64(¤tConns, 1)
return true
},
})Use buffered channels to handle bursts:
// Good: Buffered channel prevents blocking
events := make(chan *Message, 100)
// Bad: Unbuffered channel may block publishers
events := make(chan *Message)Handle errors gracefully in subscription resolvers:
WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *Event, error) {
// Validate arguments first using type-safe API
channelID, err := graph.GetE[string](graph.ArgsMap(p.Args), "channelID")
if err != nil || channelID == "" {
return nil, fmt.Errorf("channelID required")
}
// Create subscription
events := make(chan *Event, 10)
subscription := pubsub.Subscribe(ctx, "events:"+channelID)
go func() {
defer close(events)
for msg := range subscription {
var event Event
if err := json.Unmarshal(msg.Data, &event); err != nil {
log.Printf("Failed to unmarshal event: %v", err)
continue // Skip malformed events
}
events <- &event
}
}()
return events, nil
})Close all connections during shutdown:
func main() {
pubsub := graph.NewInMemoryPubSub()
handler := graph.NewHTTP(&graph.GraphContext{
PubSub: pubsub,
EnableSubscriptions: true,
})
server := &http.Server{Addr: ":8080", Handler: handler}
// Graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Close PubSub (closes all subscriptions)
pubsub.Close()
// Shutdown HTTP server
server.Shutdown(ctx)
}()
server.ListenAndServe()
}See examples/subscription for a complete working example with:
- Message subscriptions with channel filtering
- User status change subscriptions
- Authentication and authorization
- Mutations that trigger subscription events
- Background event simulator
Run the example:
cd examples/subscription
go run main.goThen test subscriptions in GraphQL Playground at http://localhost:8080/graphql
import (
"github.com/gin-gonic/gin"
"github.com/paulmanoni/go-graph"
)
func main() {
r := gin.Default()
handler := graph.NewHTTP(&graph.GraphContext{
SchemaParams: &graph.SchemaBuilderParams{...},
EnableValidation: true,
})
r.POST("/graphql", gin.WrapF(handler))
r.GET("/graphql", gin.WrapF(handler))
r.Run(":8080")
}import (
"github.com/go-chi/chi/v5"
"github.com/paulmanoni/go-graph"
)
func main() {
r := chi.NewRouter()
handler := graph.NewHTTP(&graph.GraphContext{
SchemaParams: &graph.SchemaBuilderParams{...},
})
r.Handle("/graphql", handler)
http.ListenAndServe(":8080", r)
}handler := graph.NewHTTP(&graph.GraphContext{
SchemaParams: &graph.SchemaBuilderParams{...},
})
http.Handle("/graphql", handler)
http.ListenAndServe(":8080", nil)Creates a standard HTTP handler with validation and sanitization support.
| Field | Type | Default | Description |
|---|---|---|---|
Schema |
*graphql.Schema |
nil |
Custom GraphQL schema (Option 3) |
SchemaParams |
*SchemaBuilderParams |
nil |
Builder params (Option 2) |
Playground |
bool |
false |
Enable GraphQL Playground |
Pretty |
bool |
false |
Pretty-print JSON responses |
DEBUG |
bool |
false |
Skip validation/sanitization |
EnableValidation |
bool |
false |
Enable query validation |
EnableSanitization |
bool |
false |
Enable error sanitization |
TokenExtractorFn |
func(*http.Request) string |
Bearer token | Custom token extraction |
UserDetailsFn |
func(context.Context, string) (context.Context, interface{}, error) |
nil |
Fetch user from token and optionally update context |
RootObjectFn |
func(context.Context, *http.Request) map[string]interface{} |
nil |
Custom root setup |
Note: If both Schema and SchemaParams are nil, a default hello world schema is used.
type SchemaBuilderParams struct {
QueryFields []QueryField
MutationFields []MutationField
SubscriptionFields []SubscriptionField // Optional: Real-time subscriptions
}The library includes comprehensive test coverage including unit tests and benchmarks for all features including queries, mutations, and subscriptions.
# Run all tests
go test -v
# Run tests with coverage
go test -cover
# Generate coverage report
go test -coverprofile=coverage.out
go tool cover -html=coverage.out
# Run specific test
go test -run TestSubscription_WithFilter
# Run tests for a specific file
go test -run SubscriptionThe subscription implementation includes extensive unit tests covering:
- Basic subscription creation - Creating and configuring subscriptions
- Event streaming - Testing event channels and data flow
- Event filtering - Filtering events based on subscription parameters
- Middleware integration - Applying middleware to subscriptions
- PubSub integration - Testing with in-memory and custom PubSub backends
- Context cancellation - Proper cleanup when subscriptions are cancelled
- Type generation - Auto-generating GraphQL types from Go structs
- Error handling - Proper error propagation and recovery
Example test run:
# Run all subscription tests
go test -v -run Subscription
# Run specific subscription test
go test -v -run TestSubscription_WithFilterHere's an example of how to test your custom resolvers:
func TestMySubscription(t *testing.T) {
type MyEvent struct {
ID string `json:"id"`
Message string `json:"message"`
}
sub := graph.NewSubscription[MyEvent]("myEvent").
WithResolver(func(ctx context.Context, p graph.ResolveParams) (<-chan *MyEvent, error) {
ch := make(chan *MyEvent, 1)
ch <- &MyEvent{ID: "1", Message: "test"}
close(ch)
return ch, nil
}).
BuildSubscription()
field := sub.Serve()
// Test subscription execution
result, err := field.Subscribe(graphql.ResolveParams{
Context: context.Background(),
})
if err != nil {
t.Fatalf("Subscribe error: %v", err)
}
outputCh, ok := result.(<-chan interface{})
if !ok {
t.Fatalf("Expected channel, got %T", result)
}
// Collect events
var received []MyEvent
for event := range outputCh {
received = append(received, event.(MyEvent))
}
if len(received) != 1 {
t.Errorf("Expected 1 event, got %d", len(received))
}
}See the examples directory for complete working examples:
main.go- Full example with authenticationsubscription/main.go- Real-time subscriptions with WebSocket
Comprehensive benchmarks are included to measure performance across all package operations.
# Run all benchmarks
go test -bench=. -benchmem
# Run specific benchmark
go test -bench=BenchmarkExtractBearerToken -benchmem
# Run with longer duration for more accurate results
go test -bench=. -benchmem -benchtime=5s
# Save results for comparison
go test -bench=. -benchmem > bench_results.txtPerformance metrics on Apple M1 Pro (results will vary by hardware):
| Operation | Time/op | Allocations | Description |
|---|---|---|---|
| Token Extraction | ~36 ns | 0 allocs | Bearer token from header |
| Type Registration | ~14 ns | 0 allocs | Object type caching |
| Get[string] | ~10 ns | 0 allocs | Extract string argument |
| Get[int] | ~10 ns | 0 allocs | Extract int argument |
| Get[bool] | ~10 ns | 0 allocs | Extract bool argument |
| GetRootString | ~10 ns | 0 allocs | Extract root string value |
| Operation | Time/op | Allocations | Description |
|---|---|---|---|
| Simple Schema | ~10 μs | 124 allocs | Default hello/echo schema |
| Complex Schema | ~12 μs | 148 allocs | Multiple types with nesting |
| With Subscriptions | ~16 μs | 136 allocs | Schema + subscription field |
| Multiple Subscriptions | ~16 μs | 125 allocs | Schema + multiple subscription fields |
| Operation | Time/op | Allocations | Description |
|---|---|---|---|
| Simple Query | ~816 ns | 27 allocs | Basic field selection |
| Complex Query | ~3.7 μs | 103 allocs | Nested 3 levels deep |
| Deep Query | ~2.5 μs | 72 allocs | Nested 5+ levels |
| With Aliases | ~4.6 μs | 130 allocs | Multiple field aliases |
| SecurityRules (uncached) | ~1.4–5.6 μs | 43 allocs | Default security ruleset |
| SecurityRules (cached) | ~141 ns | 1 alloc | Same ruleset via QueryASTCache |
| Depth Calculation | ~6–16 ns | 0 allocs | AST traversal |
| Alias Counting | ~20 ns | 0 allocs | AST analysis |
| Complexity Calc | ~13 ns | 0 allocs | Complexity scoring |
QueryASTCacheskips the parser on repeat queries — see Query AST Caching below.
| Operation | Time/op | Allocations | Description |
|---|---|---|---|
| Debug Mode | ~31 μs | 439 allocs | No validation/sanitization |
| With Validation | ~35 μs | 466 allocs | Query validation enabled |
| With Sanitization | ~35 μs | 560 allocs | Error sanitization enabled |
| With Auth | ~30 μs | 445 allocs | Token + user details fetch |
| GET Request | ~29 μs | 436 allocs | Query string parsing |
| Parallel (b.RunParallel) | ~19 μs | 440 allocs | Concurrent request handling |
| Custom Root Object | ~29 μs | 441 allocs | RootObjectFn wired |
| Operation | Time/op | Allocations | Description |
|---|---|---|---|
| Simple Resolver | ~234 ns | 5 allocs | Basic type resolver |
| With Arguments | ~349 ns | 9 allocs | Field arguments included |
| List Resolver | ~186 ns | 5 allocs | Array type resolver |
| Paginated | ~230 ns | 5 allocs | Pagination wrapper |
| With Input Object | ~411 ns | 10 allocs | Input type generation |
| Operation | Time/op | Allocations | Description |
|---|---|---|---|
| Generate Args From Type | ~1.3 μs | 22 allocs | Auto-generate GraphQL args from struct |
| Map Args to Struct | ~457 ns | 7 allocs | Parse and map GraphQL args to Go struct |
| Map Nested Struct | ~346 ns | 4 allocs | Parse nested struct arguments |
| Operation | Time/op | Allocations | Description |
|---|---|---|---|
| Build (Create) | ~381 ns | 10 allocs | Field construction |
| Build (Update) | ~380 ns | 10 allocs | Field construction |
| Build (Action) | ~356 ns | 10 allocs | Field construction |
| Execute (Create) | ~302 ns | 5 allocs | Resolver dispatch |
| Execute (Update) | ~443 ns | 7 allocs | Resolver dispatch + Patch build |
| Execute (Delete) | ~227 ns | 5 allocs | Resolver dispatch |
| Execute (Action) | ~232 ns | 5 allocs | Resolver dispatch |
| Decode Input | ~245 ns | 3 allocs | Cached plan, per-input decode |
| Patch.Apply | ~93 ns | 1 alloc | Copy present fields to dst |
| Operation | Time/op | Allocations | Description |
|---|---|---|---|
| GetRootInfo | ~742 ns | 12 allocs | Complex type extraction |
| GetArg (Complex) | ~1.1 μs | 15 allocs | Struct argument parsing |
| Response Sanitization | ~3.2 μs | 41 allocs | Regex error cleaning (pooled regex + error-marker fast path) |
| Cached Field Resolver | ~5.6 ns | 0 allocs | Cache hit scenario |
| Response Write | ~3.4 ns | 0 allocs | Buffer write operation |
Comprehensive benchmarks for real-time subscription operations:
| Operation | Time/op | Allocations | Description |
|---|---|---|---|
| Build Subscription | ~200–600 ns | 5–15 allocs | Create subscription resolver |
| Build Complex Subscription | ~800 ns | 17 allocs | With args, filter, middleware |
| Execute Subscription | ~589 ns | 8 allocs | Start event streaming |
| With Filter | ~3.6 μs | 20 allocs | Filter 100 events |
| With Middleware | ~1.4 μs | 13 allocs | 3 middleware layers |
| UnmarshalSubscriptionMessage | ~300 ns | 5 allocs | JSON parsing |
| Event Throughput | ~55 μs | 2024 allocs | 1000 events/subscription (dominated by event payload alloc) |
| With PubSub | ~3.4 μs | 16 allocs | PubSub integration, context-scoped per iter |
| Type Generation | ~800 ns | 15 allocs | Complex event type |
| Concurrent Subscriptions | ~1.9 μs | 36 allocs | Parallel execution |
| Schema with Subscriptions | ~16 μs | 136 allocs | Multiple subscriptions |
Key Observations:
- Subscription creation is fast (~200-800ns) with minimal allocations
- Event filtering adds minimal overhead (~1-2μs for 100 events)
- Type generation for complex events is efficient (~800ns)
- Concurrent subscription handling performs excellently (~500ns per subscription)
- PubSub integration adds negligible overhead (~1μs)
Running Subscription Benchmarks:
# Run all subscription benchmarks
go test -bench=BenchmarkSubscription -benchmem
# Run specific subscription benchmark
go test -bench=BenchmarkSubscription_WithFilter -benchmem
# Compare with and without middleware
go test -bench=BenchmarkSubscription_Execute -benchmem
go test -bench=BenchmarkSubscription_WithMiddleware -benchmemPerformance benchmarks for automatic embedded struct flattening:
| Operation | Time/op | Allocations | Description |
|---|---|---|---|
| Generate Fields (Embedded) | ~1.2 μs | 22 allocs | Single embedded struct |
| Generate Fields (Multiple) | ~1.4 μs | 28 allocs | Multiple embedded structs |
| Generate Fields (Deep) | ~1.7 μs | 36 allocs | 4-level deep embedding |
| Generate Fields (No Embedding) | ~1.1 μs | 20 allocs | Baseline comparison |
| Generate Object (Embedded) | ~1.4 μs | 24 allocs | Object with embedded fields |
| Generate Input (Embedded) | ~1.1 μs | 18 allocs | Input type with embedding |
| Generate Args (Embedded) | ~1.1 μs | 20 allocs | Args with embedded fields |
| Field Resolver (Embedded) | ~232 ns | 5 allocs | Resolve embedded field |
| Parallel Generation | ~779 ns | 22 allocs | Concurrent field generation |
| Complex Embedding | ~2.1 μs | 42 allocs | Multiple mixed embeddings |
Key Observations:
- Embedded struct field flattening adds minimal overhead (~100-600ns depending on complexity)
- Deep nesting (4+ levels) handled efficiently (~1.7μs)
- Field resolution from embedded structs is extremely fast (~232ns)
- Parallel generation scales well (~779ns vs ~1.2μs sequential)
- Complex scenarios with multiple embeddings remain performant (~2.1μs)
| Operation | Time/op | Allocations | Description |
|---|---|---|---|
| Parallel HTTP Requests | ~17 μs | 440 allocs | Concurrent request handling |
| Parallel Schema Build | ~3 μs | 104 allocs | Concurrent schema creation |
| Parallel Type Registration | ~145 ns | 0 allocs | Thread-safe type caching |
| Parallel Embedded Generation | ~779 ns | 22 allocs | Concurrent embedded field gen |
ExecuteValidationRules re-parses every incoming query by default. For steady-state traffic where
the same queries recur (persisted queries, mobile apps with a fixed query set, internal clients),
plug in a QueryASTCache to skip parsing on hits:
graph.NewHTTP(&graph.GraphContext{
ValidationRules: graph.SecurityRules,
ValidationOptions: &graph.ValidationOptions{
QueryCache: graph.NewQueryASTCache(256), // upper bound on distinct queries
},
// ...
})Observed impact on BenchmarkSecurityRules:
| ns/op | B/op | allocs/op | |
|---|---|---|---|
| Uncached | 1.4–5.6 μs | 1672 | 43 |
| Cached (hit) | 141 ns | 64 | 1 |
That's ~12× faster and ~43× fewer allocations on the cache-hit path. The cache uses an RWMutex-protected
map; when max entries is reached the map is swapped for a fresh one (coarse eviction) so memory stays
bounded without the overhead of a per-entry LRU. Cached ASTs are read-only — validation rules must not
mutate them.
Note: graphql-go's handler still parses the query a second time during execution. The cache eliminates the validation-side parse only. Fully eliminating the double-parse requires invoking
graphql.Dodirectly instead of usinghandler.Handler.
- Zero-allocation primitives: Token extraction and utility functions have zero heap allocations
- Fast validation: Query validation adds minimal overhead (~800 ns–4 μs depending on complexity), or ~141 ns with
QueryASTCacheon a hit - Type-safe arguments:
WithArg+Get[T](ArgsMap(…))keep per-call overhead in the low hundreds of ns - Efficient type generation: Auto-generating GraphQL args from structs adds minimal overhead (~1.3μs one-time cost)
- Pooled request/response paths: POST body buffers and the response-sanitization wrapper are reused via
sync.Pool, cutting per-request allocation - Sanitize-on-error fast path: Responses without an
"errors"field skip JSON unmarshal/remarshal entirely - Predictable performance: End-to-end request handling is consistently under 100μs
- Production ready: Complete stack with all security features runs well under 100μs per request
- Enable the AST cache: Set
ValidationOptions.QueryCacheto aNewQueryASTCache(n)sized for your distinct-query count — biggest single win for steady-state traffic - Enable type caching: Type registration is cached automatically — registered types are reused
- Use DEBUG mode wisely: Validation adds ~1 μs overhead on a cache miss (~0.1 μs on a hit), only disable in development
- Minimize complexity: Keep query depth under 10 levels for optimal validation performance
- Batch operations: Use concurrent requests for multiple independent queries
- Profile your resolvers: The handler overhead is minimal (~30 μs), optimize resolver logic first
Yes, absolutely. The benchmarks demonstrate excellent performance characteristics for high-load scenarios:
Based on the benchmark results:
- Handler overhead: ~60 μs per request (complete stack with all security features)
- Theoretical capacity: ~16,600 requests/second per core
- Multi-core scaling: On an 8-core system, potentially 100,000+ RPS (handler only)
-
Handler overhead is negligible: At 60 μs, the GraphQL handler represents a tiny fraction of total request time
Example breakdown (not measured, for illustration): - GraphQL handler: 60 μs (0.06%) ← Measured - Database query: 50,000 μs (50.00%) ← Example - External API calls: 45,000 μs (45.00%) ← Example - Business logic: 4,940 μs (4.94%) ← Example Total: ~100,000 μs (100 ms) -
Zero-allocation critical paths: Token extraction and argument parsing have 0 heap allocations, minimizing GC pressure
-
Thread-safe design: Parallel benchmarks show excellent concurrent performance (17 μs vs 28 μs sequential)
-
Predictable latency: Performance is consistent - no spikes or unpredictable behavior
The package handles these scenarios efficiently:
| Scenario | Handler Overhead | Notes |
|---|---|---|
| Simple queries | ~28 μs | Basic CRUD operations |
| Complex nested queries | ~28 μs | 3-5 levels deep |
| With authentication | ~27 μs | Token + user details |
| Full security stack | ~60 μs | Validation + sanitization + auth |
| Concurrent requests | ~17 μs/req | Parallel processing |
For high-load production environments:
✅ Do:
- Enable all security features (
EnableValidation,EnableSanitization) - overhead is minimal - Use connection pooling for databases (your resolvers, not this package)
- Implement resolver-level caching for expensive operations
- Monitor resolver performance (this is where bottlenecks occur)
- Use load balancing across multiple instances
- Consider rate limiting at the API gateway level
- Database queries (typically 1-100+ ms)
- External API calls (typically 10-500+ ms)
- Complex business logic
- N+1 query problems (use dataloader pattern)
❌ Don't:
- Disable security features for "performance" - they add negligible overhead
- Skip validation in production - the ~1 μs cost is worth it
- Worry about handler performance - optimize your resolvers first
- Complete stack: 966 allocations per request (~62 KB)
- Debug mode: 439 allocations per request (~33 KB)
- GC impact: Minimal on modern Go runtimes (1.18+)
- Memory footprint: Low even at 10,000+ concurrent requests
The benchmarks show:
- Linear scaling: No performance degradation with concurrency
- Type caching: Registered types reused (0 allocs after initial registration)
- Lock contention: Minimal (RWMutex on type registry)
This package may not be suitable if:
- You need sub-10 μs total latency (extremely rare requirement)
- You're running on severely resource-constrained environments (embedded systems)
- You need custom validation rules beyond depth/complexity/aliases
This package is excellent for high-load production environments. The 60 μs overhead is negligible compared to typical resolver operations. Your performance bottlenecks will be in your business logic, database queries, and external API calls - not in this GraphQL handler.
For reference:
- ✅ 100 RPS: Trivial (1% CPU on single core)
- ✅ 1,000 RPS: Easy (10% CPU on single core)
- ✅ 10,000 RPS: Manageable (multi-core, normal load)
- ✅ 100,000 RPS: Achievable (horizontal scaling + optimization)
⚠️ 1,000,000 RPS: Requires distributed architecture (but handler isn't the bottleneck)
The handler is not your problem. Focus on optimizing your resolvers.
MIT