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
/queryendpoint 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:
go get github.com/99designs/gqlgen@latest
go get github.com/vektah/gqlparser/v2
go get
github.com/gorilla/websocket
# For subscriptionsKey additions in go.mod:
github.com/99designs/gqlgen v0.17.84- Code generationgithub.com/vektah/gqlparser/v2 v2.5.31- GraphQL parsinggithub.com/gorilla/websocket v1.5.0- WebSocket supportgithub.com/hashicorp/golang-lru/v2 v2.0.7- Response cachinggithub.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 authenticationgithub.com/gin-contrib/timeout- Timeout handling moved to gqlgengithub.com/goccy/go-yaml- Was used for config parsing, now handled differentlygithub.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:
unable to parse config: [17:3] mapping key "filename_template" already definedStep 1.3: Update Go Modules
After adding and removing dependencies, update your module:
go mod tidy
go mod downloadPhase 2: Project Structure Refactoring
Step 2.1: New Directory Layout
Transform your project structure from REST/gRPC-focused to GraphQL-focused:
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 fileStep 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
// Service layer stays the sametype ProductService struct { repo *ProductRepository cache
CacheStore}func (s *ProductService) GetByID(ctx context.Context, id string) (*Product, error) {
// Existing implementation
}// Repository layer stays the sametype ProductRepository struct { db *sql.DB}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:
scalar Timetype Query {
getProduct(id: ID!): Product listProducts(limit: Int, offset: Int): ProductList!
}type Mutation {
createProduct(input: CreateProductInput!): Product! updateProduct(id: ID!, input:
UpdateProductInput!): Product! deleteProduct(id: ID!): Boolean!
}type Product {
id: ID!
name: String!
description: String
price: Float!
createdAt: Time!
updatedAt: Time!
}type ProductList {
items: [Product!]!
total: Int!
limit: Int!
offset: Int!
}input CreateProductInput {
name: String! description: String price: Float!
}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:
// Domain model (existing
)
type Product struct {
ID string
Name string
Description string
Price float64
CreatedAt time.Time
UpdatedAt time.Time
}// 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:
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.TimeStep 4.2: Generate Code
Once the configuration is correct:
gqlgen generateThis generates:
internal/graph/generated/generated.go- GraphQL executable schemainternal/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:
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:
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:
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:
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:
# 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:
- Navigate to
http://localhost:4000/playground - Write queries in the left panel
- View results in the right panel
- Use the documentation panel to explore your schema
