Arquitectura

Construcción de un gateway GraphQL en Go sobre APIs REST existentes: arquitectura y práctica

Ruslan Ismailov Publicado 12 min de lectura
C

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í →