Construcción de un gateway GraphQL en Go sobre APIs REST existentes: arquitectura y práctica
Introducción: por qué un gateway GraphQL sobre REST y cuándo tiene sentido
La arquitectura de microservicios resuelve el problema de escalabilidad e independencia de los equipos, pero genera uno nuevo: el cliente se ve obligado a realizar múltiples solicitudes HTTP a distintas APIs REST, ensamblar los datos en el frontend y lidiar con el over-fetching y el under-fetching. Una aplicación móvil solicita el usuario, sus pedidos y el estado de entrega: tres servicios distintos, tres solicitudes distintas, tres contratos distintos.
Un gateway GraphQL resuelve este problema de forma elegante: proporciona un único punto de entrada con un grafo de datos tipado, lo que permite al cliente solicitar exactamente los campos que necesita. Al mismo tiempo, los propios microservicios permanecen en REST: sin reescrituras, sin riesgo de regresiones.
Este enfoque está justificado en los siguientes casos:
- Tienes varios clientes (web, móvil, API B2B) con diferentes necesidades de datos.
- Los servicios REST fueron desarrollados por distintos equipos y cambiar sus contratos resulta costoso.
- Existe el problema de N+1 solicitudes en el cliente o de transferencia excesiva de datos.
- Se requiere autenticación y monitoreo centralizados.
Go es la elección ideal para un gateway: baja sobrecarga, una rica biblioteca estándar para HTTP y goroutines para solicitudes paralelas a servicios downstream. En este artículo recorreremos el camino desde el esquema hasta el despliegue en Kubernetes.
Esquema de arquitectura
La topología general es la siguiente. El cliente (navegador, aplicación móvil u otro servicio) envía una única solicitud GraphQL al gateway. El gateway analiza la solicitud, invoca los resolvers, cada uno de los cuales llama al servicio REST correspondiente a través de HTTP. Los resultados se agregan y se devuelven al cliente como una única respuesta JSON.
- Client → GraphQL Gateway (Go, puerto 8080)
- GraphQL Gateway → User Service (REST, puerto 3001)
- GraphQL Gateway → Order Service (REST, puerto 3002)
- GraphQL Gateway → Delivery Service (REST, puerto 3003)
- GraphQL Gateway ↔ Redis (caché, puerto 6379)
El gateway no almacena lógica de negocio: solo traduce, agrega y cachea. Esto es fundamental: el gateway debe permanecer como una capa delgada; de lo contrario, obtendrás un monolito distribuido.
Elección de la biblioteca: gqlgen vs graph-gophers/graphql-go
En el ecosistema de Go existen dos bibliotecas principales para construir servidores GraphQL.
graph-gophers/graphql-go es una biblioteca madura con un enfoque basado en reflection. Los resolvers se vinculan mediante convenciones de nombres de métodos, lo que facilita el inicio pero complica la refactorización en proyectos grandes: el compilador no ayuda a detectar discrepancias entre el esquema y el código.
gqlgen utiliza un enfoque de generación de código: defines el esquema en SDL, ejecutas go generate y la biblioteca genera interfaces de resolvers con seguridad de tipos. Implementas las interfaces y obtienes una verificación completa en tiempo de compilación. Por eso gqlgen es preferible para un gateway de producción de nivel Middle/Senior:
- El esquema es la única fuente de verdad (Schema-First).
- El código generado elimina el boilerplate y los errores de mapeo.
- Soporte integrado para DataLoader, middleware y límites de complejidad.
- En desarrollo activo: la versión 2025–2026 soporta genéricos de Go 1.22+.
Definición del esquema GraphQL (SDL)
Creamos el archivo schema.graphqls. El esquema describe el grafo de datos disponible para el cliente, independientemente de la implementación interna de los servicios REST:
type Query {
user(id: ID!): User
orders(userId: ID!): [Order!]!
}
type User {
id: ID!
name: String!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
status: OrderStatus!
total: Float!
delivery: Delivery
}
type Delivery {
trackingNumber: String!
estimatedDate: String
courier: String!
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
Tras definir el esquema, ejecutamos la generación de código:
go run github.com/99designs/gqlgen generate
gqlgen creará los archivos graph/generated/generated.go y graph/model/models_gen.go, así como stubs de resolvers en graph/resolver.go.
Implementación de resolvers: cliente HTTP y mapeo de respuestas
Cada resolver es un método de una estructura Go que implementa la interfaz generada. El resolver realiza una solicitud HTTP a la API REST y mapea la respuesta al tipo GraphQL correspondiente.
Definimos las dependencias del gateway mediante la estructura Resolver:
package graph
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/yourorg/gateway/graph/model"
)
type Resolver struct {
HTTPClient *http.Client
UserServiceURL string
OrderServiceURL string
DeliveryServiceURL string
}
func NewResolver() *Resolver {
return &Resolver{
HTTPClient: &http.Client{
Timeout: 5 * time.Second,
},
UserServiceURL: "http://user-service:3001",
OrderServiceURL: "http://order-service:3002",
DeliveryServiceURL: "http://delivery-service:3003",
}
}
Implementamos el resolver de consulta de usuario:
func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
url := fmt.Sprintf("%s/users/%s", r.Resolver.UserServiceURL, id)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
// Reenvío del token JWT
if token := ctx.Value("jwt"); token != nil {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
}
resp, err := r.Resolver.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("user service request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("user service returned %d", resp.StatusCode)
}
var restUser struct {
ID string `json:"id"`
Name string `json:"full_name"` // el campo REST puede diferir
Email string `json:"email"`
}
if err := json.NewDecoder(resp.Body).Decode(&restUser); err != nil {
return nil, fmt.Errorf("decoding user: %w", err)
}
return &model.User{
ID: restUser.ID,
Name: restUser.Name,
Email: restUser.Email,
}, nil
}
Nótese el mapeo: la API REST devuelve full_name, mientras que el esquema GraphQL utiliza name. El gateway se encarga de la normalización de contratos.
El problema N+1: DataLoader en Go
Si el cliente solicita una lista de pedidos y, para cada uno, la información de entrega, una implementación ingenua realizará 1 solicitud por los pedidos y N solicitudes por las entregas. DataLoader resuelve esto mediante batching: en lugar de N solicitudes HTTP individuales, se realiza una única solicitud en lote.
Utilizamos la biblioteca github.com/graph-gophers/dataloader/v7:
package loader
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
dl "github.com/graph-gophers/dataloader/v7"
"github.com/yourorg/gateway/graph/model"
)
type DeliveryLoader struct {
*dl.Loader[string, *model.Delivery]
}
func NewDeliveryLoader(deliveryServiceURL string, client *http.Client) *DeliveryLoader {
batchFn := func(ctx context.Context, orderIDs []string) []*dl.Result[*model.Delivery] {
// Una única solicitud batch a la API REST
url := fmt.Sprintf("%s/deliveries?order_ids=%s",
deliveryServiceURL, strings.Join(orderIDs, ","))
resp, err := client.Get(url)
results := make([]*dl.Result[*model.Delivery], len(orderIDs))
if err != nil {
for i := range results {
results[i] = &dl.Result[*model.Delivery]{Error: err}
}
return results
}
defer resp.Body.Close()
var deliveries map[string]*model.Delivery
json.NewDecoder(resp.Body).Decode(&deliveries)
for i, id := range orderIDs {
if d, ok := deliveries[id]; ok {
results[i] = &dl.Result[*model.Delivery]{Data: d}
} else {
results[i] = &dl.Result[*model.Delivery]{Data: nil}
}
}
return results
}
return &DeliveryLoader{
Loader: dl.NewBatchedLoader(batchFn),
}
}
El DataLoader se inyecta en el contexto de la solicitud a través de un middleware y se utiliza en el resolver del campo delivery del tipo Order. Esto reduce el número de solicitudes HTTP de O(N) a O(1) por lote.
Caché de respuestas con Redis
Redis permite cachear las respuestas de los servicios REST a nivel del gateway, reduciendo la carga sobre los servicios downstream. Para datos de referencia (usuarios, catálogo de productos), un TTL de 60 a 300 segundos ofrece una mejora significativa.
package cache
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type RedisCache struct {
client *redis.Client
}
func NewRedisCache(addr string) *RedisCache {
return &RedisCache{
client: redis.NewClient(&redis.Options{Addr: addr}),
}
}
func (c *RedisCache) GetUser(ctx context.Context, id string) (*UserDTO, error) {
key := fmt.Sprintf("user:%s", id)
data, err := c.client.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, nil // cache miss
}
if err != nil {
return nil, err
}
var user UserDTO
return &user, json.Unmarshal(data, &user)
}
func (c *RedisCache) SetUser(ctx context.Context, id string, user *UserDTO, ttl time.Duration) error {
key := fmt.Sprintf("user:%s", id)
data, err := json.Marshal(user)
if err != nil {
return err
}
return c.client.Set(ctx, key, data, ttl).Err()
}
En el resolver, primero se verifica el caché; si hay un fallo, se llama a la API REST y se escribe el resultado en Redis. Es importante invalidar el caché en las mutaciones: si el gateway soporta GraphQL Mutations, la mutación updateUser debe eliminar la clave user:{id} de Redis.
Autenticación y autorización: reenvío de JWT
El gateway valida el token JWT una sola vez y lo reenvía a los servicios downstream. Esto centraliza la lógica de autenticación y evita duplicarla en cada microservicio.
func JWTMiddleware(jwtSecret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
next.ServeHTTP(w, r)
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := validateJWT(tokenStr, jwtSecret)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), "jwt", tokenStr)
ctx = context.WithValue(ctx, "userID", claims.Subject)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
En los resolvers, el token se extrae del contexto y se agrega al encabezado de la solicitud HTTP saliente hacia la API REST. Los servicios downstream siguen siendo responsables de la autorización de recursos concretos: el gateway solo garantiza la identificación.
Despliegue: Docker y Kubernetes
Un Dockerfile multietapa minimiza el tamaño de la imagen:
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o gateway ./cmd/gateway
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/gateway .
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s \
CMD wget -qO- http://localhost:8080/healthz || exit 1
ENTRYPOINT ["./gateway"]
Deployment de Kubernetes con health checks y límites de recursos:
apiVersion: apps/v1
kind: Deployment
metadata:
name: graphql-gateway
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: graphql-gateway
template:
metadata:
labels:
app: graphql-gateway
spec:
containers:
- name: gateway
image: yourorg/graphql-gateway:1.0.0
ports:
- containerPort: 8080
env:
- name: USER_SERVICE_URL
value: "http://user-service.production.svc.cluster.local:3001"
- name: ORDER_SERVICE_URL
value: "http://order-service.production.svc.cluster.local:3002"
- name: REDIS_ADDR
valueFrom:
secretKeyRef:
name: redis-secret
key: addr
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
El endpoint /healthz se implementa de forma trivial: devuelve 200 OK. /readyz adicionalmente verifica la disponibilidad de Redis y de al menos un servicio downstream.
Monitoreo: trazado y métricas
Para un gateway GraphQL son importantes dos categorías de observabilidad: el trazado de solicitudes a través de los servicios y las métricas a nivel de operaciones GraphQL.
El trazado se implementa mediante OpenTelemetry. gqlgen proporciona el hook AroundOperations, que permite crear un span por cada operación GraphQL:
func NewTracingExtension(tracer trace.Tracer) graphql.HandlerExtension {
return &tracingExtension{tracer: tracer}
}
func (t *tracingExtension) InterceptOperation(
ctx context.Context,
next graphql.OperationHandler,
) graphql.ResponseHandler {
opCtx := graphql.GetOperationContext(ctx)
ctx, span := t.tracer.Start(ctx, opCtx.OperationName,
trace.WithAttributes(
attribute.String("graphql.operation.type", string(opCtx.Operation.Operation)),
),
)
defer span.End()
return next(ctx)
}
Métricas de Prometheus: registra el número de solicitudes por operación, la latencia (histograma p50/p95/p99), el número de errores y el cache hit rate de Redis. Un dashboard de Grafana con estas métricas ofrece una visión completa del estado del gateway.
Adicionalmente, conviene configurar los límites de complejidad en gqlgen para protegerse de solicitudes maliciosas con anidamiento profundo:
srv := handler.NewDefaultServer(generated.NewExecutableSchema(cfg))
srv.Use(extension.FixedComplexityLimit(100))
Conclusión: cuándo un gateway GraphQL es la elección correcta
Un gateway GraphQL en Go sobre APIs REST no es una bala de plata, pero sí una herramienta poderosa en el contexto adecuado. Se justifica cuando tienes varios clientes con necesidades distintas, servicios REST maduros que no conviene reescribir y un equipo dispuesto a mantener una capa adicional de infraestructura.
Decisiones clave tomadas en este artículo:
- gqlgen: por la generación de código y la seguridad de tipos del esquema.
- DataLoader: obligatorio para cualquier gateway con datos relacionales; sin él, el N+1 destruirá el rendimiento.
- Redis: el caché a nivel del gateway reduce la carga sobre los servicios downstream y mejora la latencia p99.
- Kubernetes con health checks y límites de recursos: el mínimo de producción para un despliegue confiable.
- OpenTelemetry + Prometheus: sin observabilidad, el gateway se convierte en una caja negra.
El gateway debe permanecer delgado: sin lógica de negocio, solo agregación, traducción y aspectos transversales (autenticación, caché, monitoreo). Si la lógica empieza a acumularse, es una señal para extraerla a un microservicio separado o revisar los límites de los existentes.
Un gateway GraphQL bien diseñado es invisible para el desarrollador final: simplemente funciona de forma rápida, predecible y segura, sin exponer la complejidad del sistema distribuido que hay detrás.
Tecnologías
Etiquetas
Ruslan Ismailov
Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →