Skip to content

paulmanoni/go-graph

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

31 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

go-graph

A modern, secure GraphQL handler for Go with built-in authentication, validation, and an intuitive builder API.

Features

  • 🚀 Zero Config Start - Default hello world schema included
  • 🔧 Type-Safe Resolvers - Compile-time type safety with generic resolvers
  • 🎯 Type-Safe Arguments - Chainable WithArg API 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 QueryASTCache drops validation cost to ~141 ns on hits

Built on top of graphql-go.

Installation

go get github.com/paulmanoni/go-graph

Quick Start

Option 1: Default Schema (Zero Config)

Start 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") }

Option 2: Builder Pattern (Recommended)

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))
}

Option 3: Custom Schema

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,
})

Authentication

Automatic Bearer Token Extraction

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()
}

Custom Token Extraction

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
    },
})

Security Features

Production Setup

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
    },
})

Validation Rules (Legacy)

For simple validation needs, use EnableValidation: true:

  • Max Query Depth: 10 levels
  • Max Aliases: 4 per query
  • Max Complexity: 200
  • Introspection: Disabled (blocks __schema and __type)

For advanced validation needs, see the Custom Validation Rules section below.

Response Sanitization (when EnableSanitization: true)

Removes field suggestions from error messages:

Before:

{
  "errors": [{
    "message": "Cannot query field \"nam\". Did you mean \"name\"?"
  }]
}

After:

{
  "errors": [{
    "message": "Cannot query field \"nam\"."
  }]
}

Debug Mode

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
})

Custom Validation Rules

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.

Features

  • 🛡️ 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)

Quick Start

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
    },
})

Security Rules

MaxDepthRule

Prevents deeply nested queries that can cause performance issues:

graph.NewMaxDepthRule(10)  // Max 10 levels deep

Blocks:

{
  level1 {
    level2 {
      level3 {
        # ... more than 10 levels
      }
    }
  }
}

MaxComplexityRule

Limits query computational cost (complexity = number of fields × depth):

graph.NewMaxComplexityRule(200)  // Max complexity of 200

Example: Query with 50 fields at depth 5 = complexity 250 → Rejected

MaxAliasesRule

Prevents alias-based denial-of-service attacks:

graph.NewMaxAliasesRule(4)  // Max 4 aliases per query

Blocks:

{
  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
}

NoIntrospectionRule

Blocks schema introspection in production:

graph.NewNoIntrospectionRule()

Blocks:

{ __schema { types { name } } }
{ __type(name: "User") { fields { name } } }

MaxTokensRule

Limits total tokens in query (prevents extremely large queries):

graph.NewMaxTokensRule(500)  // Max 500 tokens

Authentication & Authorization Rules

RequireAuthRule

Require 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"
  }]
}

RoleRule & RoleRules

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
}

PermissionRule & PermissionRules

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"},
})

BlockedFieldsRule

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")

Rate Limiting

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
}

Preset Rule Collections

Pre-configured rule sets for common scenarios:

SecurityRules (Default)

Standard security for production:

graph.ValidationRules: graph.SecurityRules
  • Max depth: 10
  • Max complexity: 200
  • Max aliases: 4
  • Introspection: Blocked

StrictSecurityRules

Stricter limits for high-security environments:

graph.ValidationRules: graph.StrictSecurityRules
  • Max depth: 8
  • Max complexity: 150
  • Max aliases: 3
  • Max tokens: 500
  • Introspection: Blocked

DevelopmentRules

Lenient rules for development:

graph.ValidationRules: graph.DevelopmentRules
  • Max depth: 20
  • Max complexity: 500
  • No other restrictions

Combining Rules

CombineRules

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,
})

Preset Role Configurations

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, manageRoles
  • ManagerFields: approveOrder, viewReports, manageTeam, bulkOperations
  • AuditorFields: viewAuditLog, exportLogs, viewAnalytics

Production Example

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)
}

Custom Validation Rules

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) *ValidationError
  • NewErrorf(format string, args ...interface{}) *ValidationError
  • Enable() / Disable() / Enabled() bool
  • Consistent error formatting

Error Responses

Single Validation Error

{
  "errors": [{
    "message": "query depth 12 exceeds maximum 10",
    "rule": "MaxDepthRule"
  }]
}

Multiple Validation Errors

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"
    }
  ]
}

Validation Options

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),
}

Performance Impact

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

Migration from EnableValidation

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()

Best Practices

  1. Start with presets: Use SecurityRules or StrictSecurityRules as a base
  2. Add auth rules: Layer in RequireAuthRule and RoleRules as needed
  3. Test in development: Use DevelopmentRules with DEBUG: true for development
  4. Collect all errors: Set StopOnFirstError: false for better debugging
  5. Monitor performance: Validation overhead is minimal (~3-5μs total)
  6. Use UserDetailsFn: Centralize auth logic by implementing UserDetailsFn instead of checking tokens in each resolver
  7. Implement minimal interfaces: Only implement the interfaces needed by your validation rules (HasRolesInterface, HasPermissionsInterface, HasIDInterface)

Helper Functions

Accessing Root Values (Generic API)

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)

Legacy API (Still Available)

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)

Type-Safe Resolvers

Automatic Type Generation with Embedded Struct Support

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.

Embedded Struct Example

// 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 *BaseEntity as 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

WithResolver - Type-Safe (Recommended)

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 nil for not found)

Real-World Example

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()
}

Chainable Arguments with WithArg

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.

Basic Usage

// 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()

Supported Argument Types

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

Args Helper Functions

// 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()

Using Args with Subscriptions and Custom Resolvers (ArgsMap)

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

Error Handling Options

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 Arguments and Defaults

// 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()

Nested Struct Support

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 Signature

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()

Complete Example

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()
}

Typed Mutations with NewMutation

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.

Why kind-based builders?

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.

Quick Start

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.

Partial Updates with Patch[In]

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)

Upserts return Result[T]

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 }
  }
}

Lifecycle Hooks

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
}

Error Model

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",
}

Middleware, Input Name, Description

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.

Performance

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.

Comparison with the older NewResolver[T]().BuildMutation() API

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].

Middleware

The library provides a powerful middleware system for adding cross-cutting concerns like authentication, logging, caching, and more to your resolvers.

Resolver Middleware

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:

  1. LoggingMiddleware (starts timer)
  2. AuthMiddleware (checks permissions)
  3. Your resolver (executes business logic)
  4. AuthMiddleware (returns)
  5. LoggingMiddleware (logs duration)

Built-in Middleware

LoggingMiddleware

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.5ms

AuthMiddleware

Requires 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()

CacheMiddleware

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()

Custom Middleware

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()

Middleware Patterns

Stacking Multiple Middleware

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()

Field-Level Middleware

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()

Permission Middleware (Convenience Method)

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()

Advanced Middleware Examples

Context Injection Middleware

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)
        }
    }
}

Error Handling Middleware

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
    }
}

Performance Tracking Middleware

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
        }
    }
}

Middleware Best Practices

  1. Order matters: Place authentication before caching, logging outermost
  2. Keep middleware pure: Avoid side effects when possible
  3. Use context for data: Pass request-scoped data via context
  4. Handle errors gracefully: Always return meaningful errors
  5. Measure performance: Use logging/metrics middleware to track slow resolvers
  6. Batch database queries: Use dataloaders to prevent N+1 queries

GraphQL Subscriptions

Real-time event streaming over WebSocket with type-safe resolvers and middleware support.

Features

  • 🔄 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) and subscriptions-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

Quick Start

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
  }
}

PubSub System

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
}

In-Memory PubSub (Development)

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
}

Custom PubSub Backends

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,
})

Subscription Resolver API

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()

Basic Subscription

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()
}

With Arguments

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()
}

With Event Filtering

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()
}

With Authentication Middleware

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()
}

With Field-Level Customization

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()
}

WebSocket Configuration

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
    },
})

Authentication in Subscriptions

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()
}

Publishing Events from Mutations

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()
}

Protocol Support

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.

Production Considerations

Connection Limits

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(&currentConns) >= int64(maxConnections) {
            return false
        }
        atomic.AddInt64(&currentConns, 1)
        return true
    },
})

Event Buffering

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)

Error Handling

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
})

Graceful Shutdown

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()
}

Complete Example

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.go

Then test subscriptions in GraphQL Playground at http://localhost:8080/graphql

Framework Integration

With Gin

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")
}

With Chi

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)
}

With Standard net/http

handler := graph.NewHTTP(&graph.GraphContext{
    SchemaParams: &graph.SchemaBuilderParams{...},
})

http.Handle("/graphql", handler)
http.ListenAndServe(":8080", nil)

API Reference

NewHTTP(graphCtx *GraphContext) http.HandlerFunc

Creates a standard HTTP handler with validation and sanitization support.

GraphContext Configuration

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.

SchemaBuilderParams

type SchemaBuilderParams struct {
    QueryFields        []QueryField
    MutationFields     []MutationField
    SubscriptionFields []SubscriptionField  // Optional: Real-time subscriptions
}

Testing

The library includes comprehensive test coverage including unit tests and benchmarks for all features including queries, mutations, and subscriptions.

Running Unit Tests

# 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 Subscription

Subscription Tests

The 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_WithFilter

Writing Tests for Your Resolvers

Here'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))
    }
}

Examples

See the examples directory for complete working examples:

  • main.go - Full example with authentication
  • subscription/main.go - Real-time subscriptions with WebSocket

Performance Benchmarks

Comprehensive benchmarks are included to measure performance across all package operations.

Running Benchmarks

# 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.txt

Benchmark Results

Performance metrics on Apple M1 Pro (results will vary by hardware):

Core Operations

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

Schema Building

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

Query Validation

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

QueryASTCache skips the parser on repeat queries — see Query AST Caching below.

HTTP Handler Performance

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

Resolver Creation

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

Argument Handling

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

Typed Mutations (NewMutation[T, In])

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

Advanced Features

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

Subscription Performance

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 -benchmem

Embedded Struct Field Generation

Performance 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)

Concurrency Performance

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

Query AST Caching

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.Do directly instead of using handler.Handler.

Key Takeaways

  • 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 QueryASTCache on 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

Optimization Tips

  1. Enable the AST cache: Set ValidationOptions.QueryCache to a NewQueryASTCache(n) sized for your distinct-query count — biggest single win for steady-state traffic
  2. Enable type caching: Type registration is cached automatically — registered types are reused
  3. Use DEBUG mode wisely: Validation adds ~1 μs overhead on a cache miss (~0.1 μs on a hit), only disable in development
  4. Minimize complexity: Keep query depth under 10 levels for optimal validation performance
  5. Batch operations: Use concurrent requests for multiple independent queries
  6. Profile your resolvers: The handler overhead is minimal (~30 μs), optimize resolver logic first

High Load Performance Analysis

Is This Package Production-Ready for High Traffic?

Yes, absolutely. The benchmarks demonstrate excellent performance characteristics for high-load scenarios:

Throughput Capacity

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)

Real-World Considerations

  1. 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)
    
  2. Zero-allocation critical paths: Token extraction and argument parsing have 0 heap allocations, minimizing GC pressure

  3. Thread-safe design: Parallel benchmarks show excellent concurrent performance (17 μs vs 28 μs sequential)

  4. Predictable latency: Performance is consistent - no spikes or unpredictable behavior

Tested Load Scenarios

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

Production Deployment Recommendations

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

⚠️ Bottlenecks will be in your code, not this package:

  • 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

Memory Efficiency

  • 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

Proven Scalability

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)

When NOT to Use This Package

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

Conclusion

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.

License

MIT

About

Type-safe GraphQL wrapper for Go - Build resolvers faster with fluent APIs, generic types, and structured responses. Built on top of graphql-go.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages