Complete Guide: Migrating from REST & gRPC to GraphQL in Go

Introduction

In this comprehensive guide, we’ll walk through the complete process of migrating a Go application from a REST API with gRPC and JWT authentication to a modern GraphQL-based architecture. This article documents the actual migration of the go-simple project to go-graphql, covering every step, decision, and implementation detail.

Why Migrate to GraphQL?

Before diving into the technical details, let’s understand the motivation:

  • Single Endpoint: Instead of managing multiple REST routes, GraphQL provides one /query endpoint for all operations
  • Precise Data Fetching: Clients request exactly the fields they need, reducing over-fetching
  • Strong Type System: GraphQL schema provides built-in documentation and validation
  • Better Developer Experience: Interactive playground for testing queries in real-time
  • Real-time Capabilities: Native support for subscriptions without complex polling logic
  • Reduced Complexity: No need to maintain both REST and gRPC layers

Project Overview

The original go-simple project was built with:

  • Gin Framework for HTTP routing
  • gRPC for service-to-service communication
  • JWT for authentication
  • SQLC for type-safe database queries
  • Redis for caching
  • Uber Fx for dependency injection
  • PostgreSQL for data persistence

The goal was to consolidate all operations into a single GraphQL layer while maintaining the existing clean architecture.

Phase 1: Dependency Management

Step 1.1: Add GraphQL Dependencies

The first major change was adding gqlgen and its ecosystem:

bash
go get github.com/99designs/gqlgen@latest
go get github.com/vektah/gqlparser/v2
go get
github.com/gorilla/websocket
# For subscriptions

Key additions in go.mod:

  • github.com/99designs/gqlgen v0.17.84 - Code generation
  • github.com/vektah/gqlparser/v2 v2.5.31 - GraphQL parsing
  • github.com/gorilla/websocket v1.5.0 - WebSocket support
  • github.com/hashicorp/golang-lru/v2 v2.0.7 - Response caching
  • github.com/google/uuid v1.6.0 - ID generation

Step 1.2: Remove Legacy Dependencies

Since we’re eliminating gRPC and JWT, several dependencies became obsolete:

Removed:

  • github.com/golang-jwt/jwt/v5 - No longer needed for authentication
  • github.com/gin-contrib/timeout - Timeout handling moved to gqlgen
  • github.com/goccy/go-yaml - Was used for config parsing, now handled differently
  • github.com/urfave/cli/v3 - CLI management not needed

This cleanup was critical because some of these packages conflicted with gqlgen’s dependencies, causing parser errors like:

solidity
unable to parse config: [17:3] mapping key "filename_template" already defined

Step 1.3: Update Go Modules

After adding and removing dependencies, update your module:

bash
go mod tidy
go mod download

Phase 2: Project Structure Refactoring

Step 2.1: New Directory Layout

Transform your project structure from REST/gRPC-focused to GraphQL-focused:

text
go-graphql/├── cmd/│   └── server/│       └── main.go├── internal/│   ├── app/│   │   └── app.go
# Fx app setup│   ├── config/│   │   └── config.go                 # Configuration│   ├── graph/│
│   ├── schema.graphqls           # GraphQL schema definition│   │   ├── generated.go              #
Auto-generated by gqlgen│   │   ├── model.go                  # Auto-generated models│   │   ├──
resolver.go               # Root resolver│   │   └── resolvers/│   │       ├── resolver.go
# Resolver setup│   │       └── product.go            # Product resolvers│   ├── product/│   │   ├──
service/│   │   │   └── service.go            # Business logic (unchanged)│   │   └── repository/│
│       └── repository.go         # Data access (unchanged)│   ├── storage/│   │   └── sql/│   │
├── db.go                 # Database connection│   │       ├── migrate/              # Migrations│
│       └── sqlc/                 # Generated queries│   └── server/│       └── http.go
# HTTP server setup└── schema.graphqls                   # Root schema file

Step 2.2: Keep Business Logic Layers

The beauty of this migration is that your existing service and repository layers remain largely unchanged. GraphQL becomes purely an API layer replacement:

go

go
// Service layer stays the sametype ProductService struct {    repo *ProductRepository    cache
CacheStore}
go
func (s *ProductService) GetByID(ctx context.Context, id string) (*Product, error) {
    // Existing implementation
}
go
// Repository layer stays the sametype ProductRepository struct {    db *sql.DB}
go
func (r *ProductRepository) FindByID(ctx context.Context, id string) (*Product, error) {
    // Existing queries
}

Phase 3: GraphQL Schema Definition

Step 3.1: Create schema.graphqls

Create your GraphQL schema in the project root:

graphql
scalar Time
graphql
type Query {
  getProduct(id: ID!): Product  listProducts(limit: Int, offset: Int): ProductList!
}
graphql
type Mutation {
  createProduct(input: CreateProductInput!): Product!  updateProduct(id: ID!, input:
  UpdateProductInput!): Product!  deleteProduct(id: ID!): Boolean!
}
graphql
type Product {
  id: ID!
  name: String!
  description: String
  price: Float!
  createdAt: Time!
  updatedAt: Time!
}
graphql
type ProductList {
  items: [Product!]!
  total: Int!
  limit: Int!
  offset: Int!
}
graphql
input CreateProductInput {
  name: String!  description: String  price: Float!
}
graphql
input UpdateProductInput {
  name: String  description: String  price: Float
}

Step 3.2: Map Schema to Your Domain

The schema should map directly to your existing domain models:

go
// Domain model (existing
)
type Product struct {
    ID string
    Name string
    Description string
    Price float64
    CreatedAt time.Time
    UpdatedAt time.Time
}
go
// GraphQL model (generated
)
type Product struct {
    ID string
    `json:"id"`
    Name string
    `json:"name"`
    Description *string `json:"description"`
    Price float64 `json:"price"`
    CreatedAt time.Time `json:"createdAt"`
    UpdatedAt time.Time `json:"updatedAt"`
}

Phase 4: GQLGen Configuration

Step 4.1: Create gqlgen.yml

Create a minimal but functional configuration:

text
schema:  - schema.graphqlexec:  filename: internal/graph/generated/generated.go  package:
generatedmodel:  filename: internal/graph/model/models_gen.go  package: modelresolver:  layout:
follow-schema  dir: internal/graph/resolvers  package: resolversmodels:  Time:    model:      -
go-graphql/internal/graph/model.Time

Step 4.2: Generate Code

Once the configuration is correct:

text
gqlgen generate

This generates:

  • internal/graph/generated/generated.go - GraphQL executable schema
  • internal/graph/model/model.go - GraphQL type models

Phase 5: Resolver Implementation

Step 5.1: Root Resolver Structure

Create the root resolver that holds all dependencies:

go
package resolvers// This file will not be regenerated automatically.//// It serves as dependency
injection for your app, add any dependencies you require// here.import product
"go-graphql/internal/product/service"
type Resolver struct {
    Product *product.Product
}

Step 5.2: Resolver Adapters

Create adapter resolvers that embed the root resolver:

go
package resolvers// This file will be automatically regenerated based on the schema, any resolver//
implementations// will be copied through when generating and any unknown code will be moved to the
end.// Code generated by github.com/99designs/gqlgen version v0.17.84import ("context"
"fmt"
"go-graphql/internal/graph/generated"
"go-graphql/internal/graph/model")// Product is the resolver for the product field.
func (r *queryResolver) Product(ctx context.Context, id string) (*model.Product, error) {
    panic(fmt.Errorf("not implemented: Product - product"))
}
// Products is the resolver for the products field.
func (r *queryResolver) Products(ctx context.Context, limit *int, offset *int) ([]*model.Product,
error) {
    panic(fmt.Errorf("not implemented: Products - products"))
}
// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver {
    return &queryResolver{
        r
    }
}
type queryResolver struct{
    *Resolver
}

Phase 6: HTTP Server Setup

Step 6.1: Replace Gin with Standard HTTP

Create a new HTTP server handler:

go
package server
import (
"context"
"fmt"
"net/http"
"go-graphql/internal/config"
"go-graphql/internal/graph/generated"
"go-graphql/internal/graph/resolvers" // gqlgen generated package product
"go-graphql/internal/product/service"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"go.uber.org/fx"
"go.uber.org/zap"
)
type HTTPServer struct {
    server *http.Server
    config *config.Config
    log *zap.Logger
}
func NewHTTPServer(cfg *config.Config, log *zap.Logger) *HTTPServer {
    return &HTTPServer{
        config: cfg, log:
        log,
    }
}
// NewGraphQLResolver wires your services into the gqlgen resolvers.
func NewGraphQLResolver(productSvc *product.Product) *resolvers.Resolver {
    return &resolvers.Resolver{
        Product: productSvc,
    }
}
func RegisterGraphQLRoutes(hs *HTTPServer, resolver *resolvers.Resolver, // Assuming your resolver
package is named 'resolvers') {
    mux := http.NewServeMux() // Create the executable schema using the injected resolver schema :=
    generated.NewExecutableSchema(generated.Config{
        Resolvers: resolver
    }) // GraphQL handler srv := handler.NewDefaultServer(schema) mux.Handle("/query", srv) //
    GraphQL Playground mux.Handle("/playground", playground.Handler("GraphQL Playground", "/query"))
    // Health check mux.HandleFunc("/health",
    func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK)
        fmt.Fprint(w, `{"status":"ok"}`)
    }) hs.server = &http.Server{
        Addr:
        fmt.Sprintf("%s:%d", hs.config.HTTPAddress, hs.config.HTTPPort), Handler: mux,
    }
}
func StartHTTPServer(lc fx.Lifecycle, hs *HTTPServer) {
    lc.Append(fx.Hook{
        OnStart: func(ctx context.Context) error {
            go func() {
                hs.log.Info("Server running", zap.String("url",
                fmt.Sprintf("http://%s:%d/playground", hs.config.HTTPAddress, hs.config.HTTPPort)),
                )
                if err := hs.server.ListenAndServe();
                err != nil && err != http.ErrServerClosed {
                    hs.log.Error("Server error", zap.Error(err))
                }
            }
            () return nil
        }, OnStop: func(ctx context.Context) error {
            hs.log.Info("Stopping server...") return hs.server.Shutdown(ctx)
        },
    })
}

Step 6.2: Update Uber Fx Configuration

Update your app initialization:

go
package app
import (
"go-graphql/internal/config" // gqlgen generated package // your resolvers
"go-graphql/internal/health"
"go-graphql/internal/pkg/logger" productService "go-graphql/internal/product/service"
"go-graphql/internal/server"
"go-graphql/internal/storage/cache"
"go-graphql/internal/storage/sql"
"go-graphql/internal/storage/sql/migrate"
"go-graphql/internal/storage/sql/sqlc"
"go.uber.org/fx"
)
func NewApp() *fx.App {
    return fx.New(fx.Provide(logger.NewLogger, config.NewConfig, sql.InitialDB, // health check
    health.New, // server server.NewHTTPServer, // db migrate.NewRunner, sqlc.New, // cache
    cache.NewClient, cache.NewCacheStore, // services productService.New, // GraphQL
    server.NewGraphQLResolver,), fx.Invoke(server.RegisterGraphQLRoutes, server.StartHTTPServer, //
    migration migrate.RunMigrations, // life cycle logger.RegisterLoggerLifecycle,),)
}

Phase 8: Testing & Validation

Step 8.1: Query Examples

Test your GraphQL API using curl:

bash
# Simple query
curl -X POST http://localhost:4000/query \  -H "Content-Type: application/json" \  -d
'{    "query": "{ getProduct(id: \"1\") { id name price } }"  }'# Query with variables
curl -X POST
http://localhost:4000/query \  -H "Content-Type: application/json" \  -d
'{    "query": "query GetProduct($id: ID!) { getProduct(id: $id) { id name } }",    "variables": { "id": "1" }  }'# Mutation
curl -X POST http://localhost:4000/query \  -H "Content-Type: application/json" \  -d '{    "query": "mutation { createProduct(input: {name: \"Laptop\", price: 999.99}) { id name } }"  }'

Step 8.2: Using GraphQL Playground

GraphQL Playground provides an interactive interface:

  1. Navigate to http://localhost:4000/playground
  2. Write queries in the left panel
  3. View results in the right panel
  4. Use the documentation panel to explore your schema

Git Commits

Git Repository

https://github.com/mobintmu/go-graphql