GraphQL + Apollo Node.js 20 React 18 Nivel Avanzado

LeadFlow CRM — API GraphQL con Apollo

Esquema completo, resolvers con DataLoader, paginación por cursor, suscripciones WebSocket y cliente React para el dashboard de ventas.

Cliente ficticio: LeadFlow CRM  ·  SaaS B2B gestión de pipeline de ventas  ·  Agencia CULTIVA IA
Tipos GraphQL
9
Contact, Company, Deal, Activity, User...
🔷
Queries / Mutations
6 / 5
+ 2 suscripciones en tiempo real
Problema N+1
Resuelto
DataLoader con batching de companies
🚀
Paginación
Relay
Cursor-based: estable en datasets grandes
📄
0
Dashboard — Pipeline de Deals (datos de ejemplo)
Prospecto
Mediaplus Agency
€12,000
20% prob.
Brandlab Studio
€8,500
15% prob.
2 deals · €20,500
Calificado
Fintech Iberia SL
€45,000
35% prob.
EcoShop Commerce
€22,000
40% prob.
2 deals · €67,000
Propuesta
GrowthHack Labs
€31,200
55% prob.
1 deal · €31,200
Negociación
Retail360 Group
€78,000
75% prob.
1 deal · €78,000
Cerrado
Saas4Teams SL
€19,800
✓ Ganado
DataPrime Analytics
€54,000
✓ Ganado
2 deals · €73,800
1
Esquema GraphQL (Schema-First)
schema.graphql
Tipos principales
schema.graphql
# ── Escalares personalizados ───────────
scalar DateTime
scalar Email

# ── Entidades del dominio ──────────────
type Contact {
  id:         ID!
  fullName:   String!
  email:      Email!
  phone:      String
  score:      Int!         # 0-100 lead score
  stage:      PipelineStage!
  tags:       [String!]!
  company:    Company     # cargado con DataLoader
  activities(limit: Int = 5): [Activity!]!
  deals:      [Deal!]!
  createdAt:  DateTime!
}

type Company {
  id:         ID!
  name:       String!
  sector:     String!
  employees:  Int
  website:    String
  contacts:   [Contact!]!
}

type Deal {
  id:          ID!
  title:       String!
  value:       Float!     # EUR
  probability: Int!       # 0-100
  stage:       DealStage!
  contact:     Contact!
  owner:       User!
  closedAt:    DateTime
  updatedAt:   DateTime!
}
Queries, Mutations, Subscriptions
schema.graphql (cont.)
enum PipelineStage {
  PROSPECT QUALIFIED PROPOSAL NEGOTIATION CLOSED
}
enum DealStage {
  OPEN WON LOST
}

# ── Paginación Relay-style ─────────────
type ContactConnection {
  edges:      [ContactEdge!]!
  pageInfo:   PageInfo!
  totalCount: Int!
}
type ContactEdge { node: Contact!; cursor: String! }
type PageInfo {
  hasNextPage:     Boolean!
  hasPreviousPage: Boolean!
  endCursor:       String
}

# ── Operaciones ───────────────────────
type Query {
  contact(id: ID!): Contact
  contacts(filter: ContactFilter,
           first: Int = 20, after: String): ContactConnection!
  deals(stage: DealStage, ownerId: ID): [Deal!]!
  pipelineSummary: PipelineSummary!
}

type Mutation {
  createContact(input: CreateContactInput!): Contact!
  updateContact(id: ID!, input: UpdateContactInput!): Contact!
  moveDeal(dealId: ID!, newStage: DealStage!): Deal!
  logActivity(input: ActivityInput!): Activity!
  assignDeal(dealId: ID!, userId: ID!): Deal!
}

type Subscription {
  dealMoved(ownerId: ID): DealMovedEvent!
  contactScoreUpdated: Contact!
}

type DealMovedEvent {
  deal:      Deal!
  fromStage: DealStage!
  toStage:   DealStage!
  movedBy:   User!
  movedAt:   DateTime!
}
2
Servidor Apollo + WebSocket (suscripciones)
server.js
Apollo Server 4 + Express + graphql-ws
src/server.js
import { ApolloServer }        from '@apollo/server';
import { expressMiddleware }   from '@apollo/server/express4';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer }     from 'ws';
import { useServer }            from 'graphql-ws/lib/use/ws';
import { PubSub }               from 'graphql-subscriptions';
import depthLimit               from 'graphql-depth-limit';
import express, { json }       from 'express';
import cors                     from 'cors';
import http                     from 'node:http';

export const pubsub = new PubSub();

const schema = makeExecutableSchema({ typeDefs, resolvers });
const app    = express();
const http   = http.createServer(app);

// WebSocket server (suscripciones)
const wss = new WebSocketServer({ server: http, path: '/graphql' });
const serverCleanup = useServer({
  schema,
  context: async (ctx) => ({
    user: await getUserFromWsToken(ctx.connectionParams?.authToken),
  }),
}, wss);

// Apollo Server HTTP
const apollo = new ApolloServer({
  schema,
  validationRules: [depthLimit(7)],   // evita queries anidadas abusivas
  plugins: [{
    async serverWillStart() {
      return { async drainServer() { await serverCleanup.dispose(); } };
    },
  }],
});

await apollo.start();

app.use('/graphql', cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }),
  json(),
  expressMiddleware(apollo, {
    context: async ({ req }) => ({
      user:        await getUserFromToken(req.headers.authorization?.replace('Bearer ', '')),
      dataSources: createDataSources(),  // DataLoaders por request
      pubsub,
    }),
  })
);

http.listen(4000, () => console.log('🚀 GraphQL: http://localhost:4000/graphql'));
3
DataLoader — Eliminando el Problema N+1
dataSources.js
Sin DataLoader — N+1 queries
PROBLEMA
// Al listar 20 contactos con su empresa:
// 1 query → SELECT * FROM contacts LIMIT 20
// 20 queries → SELECT * FROM companies WHERE id = ?
//              (una por cada contacto) ❌

const resolvers = {
  Contact: {
    company: (parent, _, { db }) =>
      db.companies.findById(parent.companyId),
      // ↑ se ejecuta 20 veces = 20 queries
  }
};
Con DataLoader — 1 query (batching)
src/dataSources.js
import DataLoader from 'dataloader';

export function createDataSources() {
  // Loader: recibe [id1, id2, ...id20] en batch
  const companyLoader = new DataLoader(async (ids) => {
    const rows = await db.query(
      `SELECT * FROM companies WHERE id = ANY($1)`,
      [ids]  // 1 sola query con IN clause ✅
    );
    const map = new Map(rows.map(r => [r.id, r]));
    return ids.map(id => map.get(id) ?? null);
    // ↑ orden preservado: req. de DataLoader
  });

  return {
    companies: {
      getById: (id) => companyLoader.load(id),
    },
    contacts: {
      getMany: ({ first, after, filter }) =>
        paginateContacts({ first, after, filter }),
      getById: (id) => db.contacts.findById(id),
    },
  };
}

// Resolver limpio
const resolvers = {
  Contact: {
    company: (parent, _, { dataSources }) =>
      dataSources.companies.getById(parent.companyId),
      // ↑ 20 llamadas → 1 sola query a BD ✅
  }
};
Escenario Sin DataLoader Con DataLoader Reducción
Listar 20 contactos + empresa 21 queries a BD 2 queries a BD -90%
Feed de 50 deals + owner + contact 101 queries 3 queries -97%
Dashboard (pipeline + actividades recientes) ~80 queries 6 queries -92%
4
Resolvers con Paginación Cursor (Relay)
resolvers.js
Query contacts con cursor
src/resolvers.js
import { GraphQLError } from 'graphql';

export const resolvers = {
  Query: {
    contacts: async (_, { filter, first = 20, after }, { dataSources }) => {
      // Decodificar cursor → offset/id
      const afterId = after
        ? Buffer.from(after, 'base64').toString('ascii')
        : null;

      const { items, hasMore, total } =
        await dataSources.contacts.getMany({ first: first + 1, afterId, filter });

      const page = items.slice(0, first);

      return {
        edges: page.map(c => ({
          node:   c,
          cursor: Buffer.from(c.id).toString('base64'),
        })),
        pageInfo: {
          hasNextPage:     items.length > first,
          hasPreviousPage: !!after,
          endCursor:       page.at(-1)
            ? Buffer.from(page.at(-1).id).toString('base64')
            : null,
        },
        totalCount: total,
      };
    },
  },

  Mutation: {
    moveDeal: async (_, { dealId, newStage }, { dataSources, user, pubsub }) => {
      if (!user) throw new GraphQLError('Not authenticated', {
        extensions: { code: 'UNAUTHENTICATED' }
      });
      const deal = await dataSources.deals.getById(dealId);
      if (!deal) throw new GraphQLError('Deal not found', {
        extensions: { code: 'NOT_FOUND' }
      });
      const updated = await dataSources.deals.updateStage(dealId, newStage);

      // Publicar evento para suscriptores WebSocket
      await pubsub.publish('DEAL_MOVED', {
        dealMoved: { deal: updated, fromStage: deal.stage,
                     toStage: newStage, movedBy: user, movedAt: new Date() },
      });

      return updated;
    },
  },

  Subscription: {
    dealMoved: {
      subscribe: (_, { ownerId }, { pubsub }) =>
        pubsub.asyncIterator(['DEAL_MOVED']),
      resolve:   (payload) => payload.dealMoved,
    },
  },
};
Cliente React — Dashboard con suscripción
src/PipelineDashboard.jsx
import {
  useQuery, useMutation, useSubscription,
  gql, ApolloClient, InMemoryCache,
  split, HttpLink,
} from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient }  from 'graphql-ws';

// Cliente con split HTTP / WebSocket
const httpLink = new HttpLink({ uri: '/graphql' });
const wsLink   = new GraphQLWsLink(createClient({
  url: 'ws://localhost:4000/graphql',
  connectionParams: { authToken: localStorage.getItem('token') },
}));
const link = split(
  ({ query }) => query.definitions.some(d => d.operation === 'subscription'),
  wsLink, httpLink
);
const client = new ApolloClient({
  link,
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          contacts: { keyArgs: ['filter'],
            merge(existing, incoming) { /* cursor merge */ }
          }
        }
      }
    }
  }),
});

// Queries / Subscriptions
const PIPELINE_QUERY = gql`
  query PipelineData {
    deals(stage: OPEN) {
      id title value probability stage
      contact { fullName company { name } }
      owner { name avatarUrl }
    }
    pipelineSummary {
      totalValue wonValue openDeals avgProbability
    }
  }
`;

const DEAL_MOVED_SUB = gql`
  subscription OnDealMoved {
    dealMoved {
      fromStage toStage movedAt
      deal { id title value stage }
      movedBy { name }
    }
  }
`;

function PipelineDashboard() {
  const { data, loading } = useQuery(PIPELINE_QUERY);

  // Actualizar cache automáticamente al recibir evento
  useSubscription(DEAL_MOVED_SUB, {
    onData: ({ client, data: { data: ev } }) => {
      client.cache.modify({
        id: client.cache.identify(ev.dealMoved.deal),
        fields: { stage: () => ev.dealMoved.toStage },
      });
    },
  });

  if (loading) return <Skeleton />;
  return <KanbanBoard deals={data.deals} summary={data.pipelineSummary} />;
}
5
Flujo de Suscripción en Tiempo Real
dealMoved WebSocket event
💼
Comercial
Mueve deal en Kanban
Mutation moveDeal
HTTP POST /graphql
📢
pubsub.publish
Evento DEAL_MOVED
🔌
WebSocket Server
graphql-ws broadcast
🖥️
Otros dashboards
Cache actualizado vía useSubscription
6
Arquitectura y Dependencias
🖥️
Apollo Server 4 + Express
Servidor HTTP para queries y mutations. Middleware de autenticación JWT en el context. Depth limit para prevenir abuso.
@apollo/server express graphql-depth-limit jsonwebtoken
WebSocket + graphql-ws
Corre en el mismo puerto HTTP. Autenticación por connectionParams. Compatible con Apollo Client split link.
ws graphql-ws graphql-subscriptions
🚀
DataLoader + Prisma
Loader por tipo de entidad, instanciado por request para evitar cache contaminado entre usuarios. Batching y deduplicación automáticos.
dataloader @prisma/client PostgreSQL
⚛️
Apollo Client 3 + React
InMemoryCache con normalización por id + __typename. Split link HTTP/WS. fetchMore para paginación. Optimistic UI en mutaciones.
@apollo/client graphql-ws React 18
🔐
Auth + Seguridad
JWT extraído en context HTTP. Token en connectionParams para WS. Errores con extension codes. Rate limit en Express.
UNAUTHENTICATED FORBIDDEN NOT_FOUND
🔷
GraphQL Codegen
Genera tipos TypeScript desde el esquema. Elimina el tipado manual de resolvers y queries. Integrado como script de prebuild.
@graphql-codegen/cli typescript-resolvers typescript-operations
7
Query Ejemplo — Vista Detalle de Contacto (1 request)
Query GraphQL
Antes: 4 endpoints REST
query ContactDetail($id: ID!) {
  contact(id: $id) {
    id fullName email phone score stage tags
    company {
      name sector employees website
    }
    activities(limit: 5) {
      type subject result date
    }
    deals {
      title value probability stage
      owner { name avatarUrl }
    }
  }
}
Respuesta JSON (ejemplo real)
application/json
{ "data": { "contact": {
    "id": "c-7f3a", "fullName": "Laura Martínez",
    "email": "l.martinez@growthlab.io",
    "score": 82, "stage": "NEGOTIATION",
    "tags": ["hot-lead", "q2-target"],
    "company": {
      "name": "GrowthHack Labs",
      "sector": "Marketing Digital",
      "employees": 48,
      "website": "growthhacklabs.io"
    },
    "activities": [
      { "type": "CALL", "subject": "Demo producto",
        "result": "POSITIVE", "date": "2026-06-12" },
      { "type": "EMAIL", "subject": "Propuesta enviada",
        "result": "OPENED", "date": "2026-06-10" }
    ],
    "deals": [{
      "title": "LeadFlow Pro — Plan Anual",
      "value": 31200, "probability": 75,
      "stage": "OPEN",
      "owner": { "name": "Carlos R." }
    }]
}}}}