Construyendo un BFF de GraphQL con Apollo Server

Optimice las APIs del frontend con GraphQL BFF y Apollo Server

Índice

El patrón Backend for Frontend (BFF) combinado con GraphQL y Apollo Server crea una arquitectura poderosa para aplicaciones web modernas.

Este enfoque le permite construir APIs optimizadas para el cliente que agregan datos de múltiples fuentes mientras mantienen una separación clara de responsabilidades.

git-flow

Entendiendo el patrón BFF

El patrón Backend for Frontend surgió como una solución a los desafíos de soportar múltiples aplicaciones frontend (web, móvil, de escritorio) con diferentes requisitos de datos. En lugar de obligar a todos los clientes a usar una única API genérica, BFF crea servicios backend dedicados adaptados a las necesidades específicas de cada cliente.

Beneficios clave de BFF

  • APIs optimizadas para el cliente: Cada frontend recibe exactamente los datos que necesita en el formato que espera
  • Reducción de la complejidad del cliente: La lógica de agregación y transformación de datos se mueve al backend
  • Evolución independiente: Los frontends pueden evolucionar sin afectar a otros clientes o servicios principales
  • Mejor rendimiento: Menos viajes de ida y vuelta y payloads más pequeños mejoran la velocidad de la aplicación
  • Autonomía de equipo: Los equipos de frontend pueden poseer su propio BFF, lo que permite iteraciones más rápidas

¿Por qué GraphQL encaja perfectamente con BFF

El lenguaje de consulta flexible de GraphQL lo hace ideal para implementaciones de BFF:

  1. Obtención precisa de datos: Los clientes solicitan solo los campos que necesitan
  2. Solicitud única: Combinar datos de múltiples fuentes en una sola consulta
  3. Tipado fuerte: El esquema proporciona un contrato claro entre frontend y backend
  4. Capacidades en tiempo real: Las suscripciones permiten actualizaciones de datos en vivo
  5. Experiencia del desarrollador: La introspección y el Playground de GraphQL simplifican el desarrollo

Configurando Apollo Server para BFF

Apollo Server proporciona una base sólida para construir capas de BFF con GraphQL. Vamos a recorrer la creación de una implementación lista para producción.

Instalación y configuración básica

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { buildSubgraphSchema } from '@apollo/subgraph';
import { gql } from 'graphql-tag';

// Defina su esquema GraphQL
const typeDefs = gql`
  type User {
    id: ID!
    email: String!
    name: String!
    orders: [Order!]!
  }

  type Order {
    id: ID!
    status: String!
    total: Float!
    items: [OrderItem!]!
  }

  type OrderItem {
    id: ID!
    productId: ID!
    quantity: Int!
    price: Float!
  }

  type Query {
    me: User
    user(id: ID!): User
    orders(userId: ID!): [Order!]!
  }
`;

// Implemente resolvers
const resolvers = {
  Query: {
    me: async (_, __, { dataSources, user }) => {
      return dataSources.userAPI.getUser(user.id);
    },
    user: async (_, { id }, { dataSources }) => {
      return dataSources.userAPI.getUser(id);
    },
    orders: async (_, { userId }, { dataSources }) => {
      return dataSources.orderAPI.getOrdersByUser(userId);
    },
  },
  User: {
    orders: async (parent, _, { dataSources }) => {
      return dataSources.orderAPI.getOrdersByUser(parent.id);
    },
  },
};

// Cree una instancia de Apollo Server
const server = new ApolloServer({
  typeDefs,
  resolvers,
});

// Inicie el servidor
const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => ({
    token: req.headers.authorization,
    dataSources: {
      userAPI: new UserAPI(),
      orderAPI: new OrderAPI(),
    },
  }),
  listen: { port: 4000 },
});

console.log(`🚀 Servidor listo en ${url}`);

Implementando fuentes de datos

Las fuentes de datos proporcionan una abstracción limpia para obtener datos de varios backends:

import { RESTDataSource } from '@apollo/datasource-rest';

class UserAPI extends RESTDataSource {
  override baseURL = 'https://api.example.com/users/';

  async getUser(id: string) {
    return this.get(`${id}`);
  }

  async getUsersByIds(ids: string[]) {
    return Promise.all(ids.map(id => this.getUser(id)));
  }

  async updateUser(id: string, data: any) {
    return this.patch(`${id}`, { body: data });
  }
}

class OrderAPI extends RESTDataSource {
  override baseURL = 'https://api.example.com/orders/';

  async getOrdersByUser(userId: string) {
    return this.get('', {
      params: { userId },
    });
  }

  async getOrder(id: string) {
    return this.get(`${id}`);
  }
}

Optimizando con DataLoader

DataLoader agrupa y almacena en caché las solicitudes para evitar el problema N+1:

import DataLoader from 'dataloader';

const createUserLoader = (userAPI: UserAPI) => 
  new DataLoader(async (ids: readonly string[]) => {
    const users = await userAPI.getUsersByIds([...ids]);
    return ids.map(id => users.find(user => user.id === id));
  });

// Use en contexto
const context = async ({ req }) => ({
  dataSources: {
    userAPI: new UserAPI(),
    orderAPI: new OrderAPI(),
  },
  loaders: {
    userLoader: createUserLoader(new UserAPI()),
  },
});

Patrones avanzados de BFF

Agregando múltiples servicios

Una de las fortalezas principales de BFF es combinar datos de múltiples servicios backend:

const resolvers = {
  Query: {
    dashboard: async (_, __, { dataSources, user }) => {
      // Obtenga datos de múltiples servicios en paralelo
      const [userData, orders, recommendations, analytics] = await Promise.all([
        dataSources.userAPI.getUser(user.id),
        dataSources.orderAPI.getRecentOrders(user.id),
        dataSources.recommendationAPI.getRecommendations(user.id),
        dataSources.analyticsAPI.getUserStats(user.id),
      ]);

      return {
        user: userData,
        recentOrders: orders,
        recommendations,
        stats: analytics,
      };
    },
  },
};

Manejo de errores y resiliencia

Implemente un manejo robusto de errores para BFF en producción:

import { GraphQLError } from 'graphql';

const resolvers = {
  Query: {
    user: async (_, { id }, { dataSources }) => {
      try {
        const user = await dataSources.userAPI.getUser(id);
        
        if (!user) {
          throw new GraphQLError('Usuario no encontrado', {
            extensions: {
              code: 'NOT_FOUND',
              http: { status: 404 },
            },
          });
        }
        
        return user;
      } catch (error) {
        if (error instanceof GraphQLError) throw error;
        
        throw new GraphQLError('Fallo al obtener usuario', {
          extensions: {
            code: 'INTERNAL_SERVER_ERROR',
            originalError: error.message,
          },
        });
      }
    },
  },
};

Estrategias de caché

Implemente una caché eficiente para mejorar el rendimiento:

import { KeyValueCache } from '@apollo/utils.keyvaluecache';
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  cache: new InMemoryLRUCache({
    maxSize: Math.pow(2, 20) * 100, // 100 MB
    ttl: 300, // 5 minutos
  }),
  plugins: [
    {
      async requestDidStart() {
        return {
          async willSendResponse({ response, contextValue }) {
            // Establezca encabezados de control de caché
            response.http.headers.set(
              'Cache-Control',
              'max-age=300, public'
            );
          },
        };
      },
    },
  ],
});

Autenticación y autorización

Proteja su BFF con autenticación y autorización adecuadas:

import jwt from 'jsonwebtoken';

const context = async ({ req }) => {
  const token = req.headers.authorization?.replace('Bearer ', '');
  
  if (!token) {
    return { user: null };
  }
  
  try {
    const user = jwt.verify(token, process.env.JWT_SECRET);
    return { user };
  } catch (error) {
    throw new GraphQLError('Token de autenticación inválido', {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
};

// Proteja resolvers
const resolvers = {
  Query: {
    me: (_, __, { user }) => {
      if (!user) {
        throw new GraphQLError('Debes iniciar sesión', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
      return user;
    },
  },
};

Apollo Federation para microservicios

Cuando trabaje con múltiples equipos y servicios, Apollo Federation permite una arquitectura GraphQL distribuida:

import { buildSubgraphSchema } from '@apollo/subgraph';

const typeDefs = gql`
  extend schema
    @link(url: "https://specs.apollo.dev/federation/v2.3",
          import: ["@key", "@shareable"])

  type User @key(fields: "id") {
    id: ID!
    email: String!
    name: String!
  }
`;

const resolvers = {
  User: {
    __resolveReference: async (reference, { dataSources }) => {
      return dataSources.userAPI.getUser(reference.id);
    },
  },
};

const server = new ApolloServer({
  schema: buildSubgraphSchema({ typeDefs, resolvers }),
});

Consejos para optimizar el rendimiento

  1. Use DataLoader: Siempre implemente DataLoader para agrupar y almacenar en caché las solicitudes
  2. Implemente caché a nivel de campo: Almacene en caché cálculos costosos a nivel de campo
  3. Análisis de complejidad de consulta: Límite la profundidad y la complejidad de las consultas para prevenir el abuso
  4. Consultas persistidas: Use consultas persistidas en producción para reducir el tamaño de la carga
  5. Compresión de respuestas: Active la compresión gzip/brotli para las respuestas
  6. Monitoreo del rendimiento de consultas: Rastree consultas lentas y optimice los resolvers
  7. Use CDN para esquemas estáticos: Almacene en caché consultas de introspección en el borde

Pruebas de su BFF

Escriba pruebas comprensivas para su BFF de GraphQL:

import { ApolloServer } from '@apollo/server';

describe('Consultas de usuario', () => {
  let server: ApolloServer;

  beforeAll(() => {
    server = new ApolloServer({
      typeDefs,
      resolvers,
    });
  });

  it('obtiene usuario por id', async () => {
    const result = await server.executeOperation({
      query: `
        query GetUser($id: ID!) {
          user(id: $id) {
            id
            email
            name
          }
        }
      `,
      variables: { id: '123' },
    });

    expect(result.body.kind).toBe('single');
    if (result.body.kind === 'single') {
      expect(result.body.singleResult.data?.user).toEqual({
        id: '12键',
        email: 'test@example.com',
        name: 'Usuario de prueba',
      });
    }
  });
});

Consideraciones de despliegue

Contenedores

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 4000

CMD ["node", "dist/index.js"]

Configuración de entorno

Use variables de entorno para la configuración:

const config = {
  port: process.env.PORT || 4000,
  userServiceUrl: process.env.USER_SERVICE_URL,
  orderServiceUrl: process.env.ORDER_SERVICE_URL,
  jwtSecret: process.env.JWT_SECRET,
  nodeEnv: process.env.NODE_ENV || 'development',
};

Monitoreo y observabilidad

Implemente un monitoreo completo:

import { ApolloServerPluginInlineTrace } from '@apollo/server/plugin/inlineTrace';
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    ApolloServerPluginInlineTrace(),
    process.env.NODE_ENV === 'production'
      ? ApolloServerPluginLandingPageDisabled()
      : ApolloServerPluginLandingPageLocalDefault(),
    {
      async requestDidStart() {
        const start = Date.now();
        return {
          async willSendResponse({ operationName, contextValue }) {
            const duration = Date.now() - start;
            console.log(`Operación ${operationName} tomó ${duration}ms`);
          },
        };
      },
    },
  ],
});

Puntos comunes a evitar

  1. Problema N+1 de consulta: Siempre use DataLoader para datos relacionados
  2. Sobrecarga de datos desde el backend: Optimice las consultas del backend según los conjuntos de selección de GraphQL
  3. Falta de manejo de errores: Implemente un manejo adecuado de errores y registro
  4. Sin limitación de tasa: Proteja su BFF del abuso con limitación de tasa
  5. Ignorar la seguridad: Valide entradas, implemente autenticación y limite la complejidad de consulta
  6. Diseño de esquema pobre: Diseñe esquemas pensando en las necesidades del cliente
  7. Sin estrategia de caché: Implemente caché en múltiples niveles

Enlaces útiles