Tutorial GraphQL Node.js TypeScript: API de producción con Apollo Server, Prisma y PostgreSQL
Aprende a construir una API GraphQL de producción con Node.js 18+, Apollo Server, TypeScript, Prisma y PostgreSQL, usando buenas prácticas de seguridad, rendimiento y DX. Este tutorial está pensado para desarrolladores backend principiante-intermedio que conocen JavaScript/TypeScript y HTTP/REST, y quieren dar el salto a GraphQL en un entorno real.
Diagrama del flujo de datos:
1 2 3 4 5 6 7 8 9 10 11 |
Cliente (HTTP/WS) | \ | Query/Mut \ Subscription v v [Apollo Server] [WS Server graphql-ws] | Context (user, prisma, loaders, logger, cache) v [Resolvers] ---> [DataLoader] ---> [Prisma Client] ---> [PostgreSQL] \ -> [Redis cache/pubsub opcional] |
1) Requisitos e instalación (comprobación rápida)
Resumen: Instalaremos y verificaremos Node 18+, gestor de paquetes, Git, Docker, Docker Compose, PostgreSQL y Prisma CLI.
Pasos:
- Asegúrate de tener Node 18+ y un gestor de paquetes (pnpm recomendado). También necesitarás Docker y Git.
- Opcional: protoc si vas a añadir codegen en el futuro.
Comandos de verificación:
1 2 3 4 5 6 7 |
node -v pnpm -v # o npm -v / yarn -v docker --version docker compose version psql --version npx prisma -v |
Explicación: Validamos que las herramientas clave estén instaladas. Prisma se ejecuta vía npx, por lo que no es obligatorio instalarlo globalmente.
Checklist de la sección:
- [ ] Node 18+ instalado y verificado
- [ ] pnpm/npm/yarn verificado
- [ ] Docker y Docker Compose listos
- [ ] psql accesible (o usarás Docker para la DB)
- [ ] Prisma CLI accesible con npx
2) Inicialización del proyecto y configuración base
Resumen: Crearemos el repositorio, la estructura de carpetas, TypeScript, scripts NPM y herramientas de calidad (ESLint/Prettier). Usaremos pnpm.
Pasos:
1 2 3 4 5 6 7 8 |
mkdir graphql-apollo-prisma && cd graphql-apollo-prisma git init pnpm init -y pnpm add @apollo/server graphql cors body-parser express dotenv zod jsonwebtoken bcryptjs dataloader pino pino-pretty prom-client express-rate-limit helmet graphql-ws ws ioredis xss @prisma/client pnpm add -D typescript ts-node-dev @types/node @types/express @types/jsonwebtoken @types/bcryptjs @types/cors @types/ws @types/helmet @types/express-rate-limit prisma jest ts-jest @types/jest supertest @types/supertest eslint prettier eslint-config-prettier eslint-plugin-import eslint-plugin-unused-imports npx prisma init --datasource-provider postgresql npx tsc --init --target ES2021 --module commonjs --rootDir src --outDir dist --esModuleInterop --resolveJsonModule --strict |
Explicación: Instalamos dependencias para Apollo Server v4 con Express, autenticación JWT, validación con Zod, DataLoader, logging, métricas, WebSockets para subscriptions, Redis opcional, Prisma y herramientas de testing/lint.
Estructura recomendada del repo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
. ├── package.json ├── tsconfig.json ├── .env.example ├── prisma/ │ ├── schema.prisma │ └── seed.ts ├── src/ │ ├── index.ts │ ├── schema.graphql │ ├── prisma.ts │ ├── context.ts │ ├── auth/ │ │ ├── jwt.ts │ │ └── auth.middleware.ts │ ├── loaders/ │ │ └── dataloader.ts │ ├── modules/ │ │ ├── users/ │ │ │ └── resolvers.ts │ │ ├── posts/ │ │ │ └── resolvers.ts │ │ └── comments/ │ │ └── resolvers.ts │ ├── resolvers/ │ │ ├── query.ts │ │ ├── mutation.ts │ │ ├── subscription.ts │ │ ├── user.ts │ │ └── post.ts │ └── utils/ │ └── sanitize.ts ├── tests/ │ └── example.e2e.ts ├── docker/ │ └── wait-for.sh ├── docker-compose.yml ├── Dockerfile ├── jest.config.ts └── .github/workflows/ci.yml |
Explicación: Estructuramos por dominios y separamos resolvers y utilidades para escalabilidad.
Archivo package.json (scripts clave):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
{ "name": "graphql-apollo-prisma", "version": "1.0.0", "type": "commonjs", "scripts": { "dev": "ts-node-dev --respawn --transpile-only src/index.ts", "build": "tsc", "start": "node dist/index.js", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev --name init", "prisma:deploy": "prisma migrate deploy", "prisma:seed": "ts-node-dev prisma/seed.ts", "lint": "eslint 'src/**/*.ts'", "test": "jest --runInBand" }, "dependencies": {}, "devDependencies": {} } |
Explicación: Scripts para desarrollo, build, migraciones y seed. Ajusta devDependencies y dependencies automáticamente con pnpm.
tsconfig.json mínimo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
{ "compilerOptions": { "target": "ES2021", "module": "commonjs", "rootDir": "src", "outDir": "dist", "strict": true, "esModuleInterop": true, "resolveJsonModule": true, "skipLibCheck": true }, "include": ["src", "prisma/seed.ts"] } |
Explicación: Configuración estricta de TypeScript para Node 18.
Opcional: Husky + lint-staged
1 2 3 4 5 |
pnpm dlx husky-init && pnpm install pnpm add -D lint-staged # En package.json # "lint-staged": { "src/**/*.ts": ["eslint --fix", "prettier --write"] } |
Explicación: Añade pre-commits automáticos para calidad de código.
Checklist:
- [ ] Dependencias instaladas
- [ ] Scripts npm listos
- [ ] tsconfig configurado
- [ ] Husky/lint-staged opcional
3) Diseño del esquema GraphQL (SDL) para producción
Resumen: Usaremos enfoque schema-first con SDL en schema.graphql, incorporando tipos, entradas, enums y paginación cursor-based estilo Relay.
Diferencias: schema-first (SDL) te da contrato explícito y revisión independiente; code-first genera SDL desde código. Elegimos SDL por claridad y portabilidad.
Archivo src/schema.graphql:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
"""Roles de usuario""" enum Role { USER ADMIN } """Información base del perfil""" type User { id: ID! email: String! name: String role: Role! posts(first: Int, after: String): PostConnection! createdAt: String! updatedAt: String! } type Post { id: ID! title: String! content: String! author: User! comments(first: Int, after: String): CommentConnection! createdAt: String! updatedAt: String! } type Comment { id: ID! content: String! author: User! post: Post! createdAt: String! } """Paginación estilo Relay""" type PageInfo { endCursor: String hasNextPage: Boolean! } type PostEdge { cursor: String! node: Post! } type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! totalCount: Int! } type CommentEdge { cursor: String! node: Comment! } type CommentConnection { edges: [CommentEdge!]! pageInfo: PageInfo! totalCount: Int! } input RegisterInput { email: String!, password: String!, name: String } input LoginInput { email: String!, password: String! } input CreatePostInput { title: String!, content: String! } input CreateCommentInput { postId: ID!, content: String! } """Tokens JWT""" type AuthPayload { accessToken: String!, refreshToken: String! } """Consultas""" type Query { me: User user(id: ID!): User posts(first: Int, after: String): PostConnection! } """Mutaciones""" type Mutation { register(input: RegisterInput!): User! login(input: LoginInput!): AuthPayload! refreshToken(refreshToken: String!): AuthPayload! createPost(input: CreatePostInput!): Post! createComment(input: CreateCommentInput!): Comment! } """Suscripciones""" type Subscription { onNewPost: Post! } |
Explicación: Definimos entidades, conexiones con edges para paginación y operaciones. Las suscripciones emitirán nuevos posts.
Checklist:
- [ ] SDL creado con tipos y paginación cursor-based
- [ ] Queries/Mutations/Subscriptions separadas
- [ ] Inputs validados en resolvers
4) Prisma + PostgreSQL (modelado, migraciones y seed)
Resumen: Modelaremos User, Post y Comment con relaciones y timestamps. Ejecutaremos migraciones y un seed básico.
prisma/schema.prisma:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique password String name String? role Role @default(USER) posts Post[] comments Comment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? } model Post { id String @id @default(cuid()) title String content String authorId String author User @relation(fields: [authorId], references: [id]) comments Comment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId]) } model Comment { id String @id @default(cuid()) content String authorId String postId String author User @relation(fields: [authorId], references: [id]) post Post @relation(fields: [postId], references: [id]) createdAt DateTime @default(now()) @@index([postId]) } enum Role { USER ADMIN } |
Explicación: Modelos con relaciones 1-N, timestamps y soft delete opcional vía deletedAt.
Archivo .env.example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# App PORT=4000 NODE_ENV=development JWT_ACCESS_SECRET=replace_me_access JWT_REFRESH_SECRET=replace_me_refresh JWT_ACCESS_TTL=15m JWT_REFRESH_TTL=7d CORS_ORIGIN=http://localhost:3000 # DB DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app?schema=public # Redis (opcional) REDIS_URL=redis://localhost:6379 # Metrics ENABLE_METRICS=true |
Explicación: Variables para servidor, JWT, DB y métricas. Copia a .env.
Seed (prisma/seed.ts):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import { PrismaClient } from '@prisma/client' import bcrypt from 'bcryptjs' const prisma = new PrismaClient() async function main() { const password = await bcrypt.hash('password123', 10) const alice = await prisma.user.upsert({ where: { email: 'alice@example.com' }, update: {}, create: { email: 'alice@example.com', password, name: 'Alice', role: 'ADMIN' } }) await prisma.post.create({ data: { title: 'Hola GraphQL', content: 'Primer post', authorId: alice.id } }) console.log('Seed completo') } main().finally(() => prisma.$disconnect()) |
Explicación: Crea un usuario admin y un post de ejemplo.
Comandos:
1 2 3 4 5 |
cp .env.example .env pnpm prisma:generate pnpm prisma:migrate pnpm prisma:seed |
Explicación: Genera el Prisma Client, aplica migraciones y siembra datos.
Checklist:
- [ ] Modelos definidos
- [ ] Migraciones aplicadas
- [ ] Seed ejecutado
5) Contexto, autenticación y autorización (JWT + roles)
Resumen: Implementaremos login/register con JWT, extracción de usuario desde Authorization y checks de autorización por rol/propiedad.
src/prisma.ts:
1 2 3 4 5 6 7 8 |
import { PrismaClient } from '@prisma/client' const globalForPrisma = global as unknown as { prisma?: PrismaClient } export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: ['error', 'warn'] }) if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma |
Explicación: Singleton de Prisma para evitar múltiples conexiones en dev.
src/auth/jwt.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import jwt from 'jsonwebtoken' const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET! const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET! export interface JwtPayload { sub: string; role: 'USER' | 'ADMIN' } export function signAccessToken(payload: JwtPayload) { return jwt.sign(payload, ACCESS_SECRET, { expiresIn: process.env.JWT_ACCESS_TTL || '15m' }) } export function signRefreshToken(payload: JwtPayload) { return jwt.sign(payload, REFRESH_SECRET, { expiresIn: process.env.JWT_REFRESH_TTL || '7d' }) } export function verifyAccess(token: string): JwtPayload { return jwt.verify(token, ACCESS_SECRET) as JwtPayload } export function verifyRefresh(token: string): JwtPayload { return jwt.verify(token, REFRESH_SECRET) as JwtPayload } |
Explicación: Firma y verificación de tokens JWT de acceso y refresh.
src/auth/auth.middleware.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import type { Request } from 'express' import { verifyAccess } from './jwt' export function getUserFromRequest(req: Request) { const auth = req.headers['authorization'] || '' const token = auth.startsWith('Bearer ') ? auth.slice(7) : null if (!token) return null try { return verifyAccess(token) } catch { return null } } |
Explicación: Extrae el usuario actual desde el header Authorization Bearer.
src/context.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import type { ExpressContextFunctionArgument } from '@apollo/server/express4' import pino from 'pino' import { prisma } from './prisma' import { createLoaders } from './loaders/dataloader' import { getUserFromRequest } from './auth/auth.middleware' export type AppContext = { prisma: typeof prisma loaders: ReturnType<typeof createLoaders> currentUser: { sub: string; role: 'USER' | 'ADMIN' } | null logger: pino.Logger } export function buildContext({ req }: ExpressContextFunctionArgument): AppContext { const currentUser = getUserFromRequest(req) const logger = pino({ level: process.env.NODE_ENV === 'production' ? 'info' : 'debug' }) return { prisma, loaders: createLoaders(prisma), currentUser, logger } } |
Explicación: Inyecta Prisma, DataLoaders, usuario actual y logger en context.
Autorización utilitaria (src/modules/users/resolvers.ts ejemplificará owner/admin checks). Veremos los guards en resolvers de Mutations.
Checklist:
- [ ] JWT creado con access/refresh
- [ ] Usuario inyectado en context
- [ ] Guards listos en resolvers
6) Resolvers, DataLoader y batching (anti N+1)
Resumen: Implementaremos resolvers por dominio y DataLoader para agrupar consultas N+1 como Post.author y Comment.author.
src/loaders/dataloader.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import DataLoader from 'dataloader' import type { PrismaClient } from '@prisma/client' export function createLoaders(prisma: PrismaClient) { const userById = new DataLoader(async (ids: readonly string[]) => { const users = await prisma.user.findMany({ where: { id: { in: ids as string[] } } }) const map = new Map(users.map(u => [u.id, u])) return ids.map(id => map.get(id) || null) }) return { userById } } |
Explicación: DataLoader batch por IDs de usuario. Mejora rendimiento resolviendo N+1.
src/utils/sanitize.ts:
1 2 3 |
import xss from 'xss' export const sanitize = (s: string) => xss(s) |
Explicación: Sanitiza strings para prevenir XSS en campos renderizados.
src/resolvers/query.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
import { GraphQLError } from 'graphql' import type { AppContext } from '../context' export const Query = { me: async (_: any, __: any, ctx: AppContext) => { if (!ctx.currentUser) return null return ctx.prisma.user.findUnique({ where: { id: ctx.currentUser.sub } }) }, user: (_: any, { id }: { id: string }, ctx: AppContext) => ctx.prisma.user.findUnique({ where: { id } }), posts: async (_: any, { first = 10, after }: { first?: number; after?: string }, ctx: AppContext) => { const take = Math.min(first, 50) const cursor = after ? { id: after } : undefined const items = await ctx.prisma.post.findMany({ take: take + 1, cursor, skip: after ? 1 : 0, orderBy: { createdAt: 'desc' } }) const edges = items.slice(0, take).map(p => ({ cursor: p.id, node: p })) return { edges, pageInfo: { endCursor: edges.at(-1)?.cursor, hasNextPage: items.length > take }, totalCount: await ctx.prisma.post.count() } } } |
Explicación: Implementa me, user y posts con paginación cursor-based y límite de página.
src/resolvers/user.ts y post.ts (resolvers de campos):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
import type { AppContext } from '../context' export const User = { posts: async (user: any, args: { first?: number; after?: string }, ctx: AppContext) => { const take = Math.min(args.first ?? 10, 50) const cursor = args.after ? { id: args.after } : undefined const items = await ctx.prisma.post.findMany({ where: { authorId: user.id }, take: take + 1, cursor, skip: args.after ? 1 : 0, orderBy: { createdAt: 'desc' } }) const edges = items.slice(0, take).map(p => ({ cursor: p.id, node: p })) return { edges, pageInfo: { endCursor: edges.at(-1)?.cursor, hasNextPage: items.length > take }, totalCount: await ctx.prisma.post.count({ where: { authorId: user.id } }) } } } export const Post = { author: (post: any, _: any, ctx: AppContext) => ctx.loaders.userById.load(post.authorId), comments: async (post: any, args: { first?: number; after?: string }, ctx: AppContext) => { const take = Math.min(args.first ?? 10, 50) const cursor = args.after ? { id: args.after } : undefined const items = await ctx.prisma.comment.findMany({ where: { postId: post.id }, take: take + 1, cursor, skip: args.after ? 1 : 0, orderBy: { createdAt: 'desc' } }) const edges = items.slice(0, take).map(c => ({ cursor: c.id, node: c })) const totalCount = await ctx.prisma.comment.count({ where: { postId: post.id } }) return { edges, pageInfo: { endCursor: edges.at(-1)?.cursor, hasNextPage: items.length > take }, totalCount } } } |
Explicación: Resolver de campos que usa DataLoader para author y aplica paginación en posts y comentarios.
src/resolvers/mutation.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
import { GraphQLError } from 'graphql' import bcrypt from 'bcryptjs' import { z } from 'zod' import { signAccessToken, signRefreshToken } from '../auth/jwt' import type { AppContext } from '../context' import { sanitize } from '../utils/sanitize' const registerSchema = z.object({ email: z.string().email(), password: z.string().min(8), name: z.string().optional() }) const loginSchema = z.object({ email: z.string().email(), password: z.string().min(8) }) const createPostSchema = z.object({ title: z.string().min(1), content: z.string().min(1) }) const createCommentSchema = z.object({ postId: z.string().min(1), content: z.string().min(1) }) export const Mutation = { register: async (_: any, { input }: any, ctx: AppContext) => { const data = registerSchema.parse(input) const exists = await ctx.prisma.user.findUnique({ where: { email: data.email } }) if (exists) throw new GraphQLError('Email ya registrado') const password = await bcrypt.hash(data.password, 10) return ctx.prisma.user.create({ data: { email: data.email, password, name: data.name } }) }, login: async (_: any, { input }: any, ctx: AppContext) => { const data = loginSchema.parse(input) const user = await ctx.prisma.user.findUnique({ where: { email: data.email } }) if (!user || !(await bcrypt.compare(data.password, user.password))) throw new GraphQLError('Credenciales inválidas') return { accessToken: signAccessToken({ sub: user.id, role: user.role as any }), refreshToken: signRefreshToken({ sub: user.id, role: user.role as any }) } }, refreshToken: async (_: any, { refreshToken }: { refreshToken: string }, ctx: AppContext) => { const { verifyRefresh } = await import('../auth/jwt') const payload = verifyRefresh(refreshToken) return { accessToken: signAccessToken(payload), refreshToken: signRefreshToken(payload) } }, createPost: async (_: any, { input }: any, ctx: AppContext) => { if (!ctx.currentUser) throw new GraphQLError('No autenticado') const data = createPostSchema.parse(input) const post = await ctx.prisma.post.create({ data: { title: sanitize(data.title), content: sanitize(data.content), authorId: ctx.currentUser.sub } }) ctx.pubsub?.publish('NEW_POST', { onNewPost: post }) ctx.loaders.userById.clear(post.authorId) return post }, createComment: async (_: any, { input }: any, ctx: AppContext) => { if (!ctx.currentUser) throw new GraphQLError('No autenticado') const data = createCommentSchema.parse(input) const comment = await ctx.prisma.comment.create({ data: { postId: data.postId, content: sanitize(data.content), authorId: ctx.currentUser.sub } }) return comment } } |
Explicación: Validamos inputs con Zod, sanitizamos strings, aplicamos autenticación y publicamos eventos para subscriptions.
src/resolvers/subscription.ts:
1 2 3 4 5 6 |
export const Subscription = { onNewPost: { subscribe: (_: any, __: any, { pubsub }: any) => pubsub.asyncIterator('NEW_POST') } } |
Explicación: Suscripción a nuevos posts usando un PubSub simple (veremos su inyección en index.ts).
Checklist:
- [ ] DataLoader implementado
- [ ] Resolvers organizados por dominio
- [ ] Cache del loader invalidada en mutaciones relevantes
7) Subscriptions y tiempo real (graphql-ws)
Resumen: Configuraremos graphql-ws con ws. Para escalar, podrás usar Redis Pub/Sub. Localmente usaremos PubSub en memoria.
src/index.ts (servidor Express + Apollo + WS + métricas):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
import 'dotenv/config' import express from 'express' import http from 'http' import cors from 'cors' import helmet from 'helmet' import rateLimit from 'express-rate-limit' import bodyParser from 'body-parser' import { readFileSync } from 'fs' import { join } from 'path' import { makeExecutableSchema } from '@graphql-tools/schema' import { ApolloServer } from '@apollo/server' import { expressMiddleware } from '@apollo/server/express4' import { buildContext, type AppContext } from './context' import { Query } from './resolvers/query' import { Mutation } from './resolvers/mutation' import { Subscription } from './resolvers/subscription' import { User, Post } from './resolvers/user' import { responseCachePlugin } from '@apollo/server/plugin/responseCache' import { createServer } from 'http' import { WebSocketServer } from 'ws' import { useServer } from 'graphql-ws/lib/use/ws' import { PubSub } from 'graphql-subscriptions' import client from 'prom-client' const typeDefs = readFileSync(join(__dirname, 'schema.graphql'), 'utf8') const resolvers = { Query, Mutation, Subscription, User, Post } const schema = makeExecutableSchema({ typeDefs, resolvers }) const app = express() const httpServer = http.createServer(app) const pubsub = new PubSub() // Seguridad y límites app.use(helmet()) app.use(cors({ origin: process.env.CORS_ORIGIN?.split(',') ?? '*', credentials: true })) app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 300 })) // Métricas Prometheus if (process.env.ENABLE_METRICS === 'true') { const register = new client.Registry() client.collectDefaultMetrics({ register }) app.get('/metrics', async (_req, res) => { res.set('Content-Type', register.contentType) res.end(await register.metrics()) }) } const server = new ApolloServer<AppContext>({ schema, plugins: [responseCachePlugin()], introspection: process.env.NODE_ENV !== 'production' }) // WS Server para subscriptions defineWS(httpServer, schema) await server.start() app.use('/graphql', bodyParser.json(), expressMiddleware(server, { context: async (ctx) => ({ ...buildContext(ctx), pubsub }) as any })) // Health checks app.get('/healthz', (_req, res) => res.send('ok')) app.get('/readyz', (_req, res) => res.send('ready')) const port = Number(process.env.PORT) || 4000 httpServer.listen(port, () => console.log(`GraphQL listo en http://localhost:${port}/graphql`)) function defineWS(server: http.Server, schema: any) { const wsServer = new WebSocketServer({ server, path: '/graphql' }) useServer({ schema, context: () => ({ pubsub }) as any }, wsServer) } |
Importante: Asegúrate de tener instalados los imports mencionados en package.json.
Explicación: Montamos Apollo con Express, habilitamos seguridad, métricas y levantamos un servidor WS en la misma ruta para subscriptions con graphql-ws.
Probar suscripciones localmente (node script rápido):
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// scripts/subscription-test.ts import { createClient } from 'graphql-ws' const client = createClient({ url: 'ws://localhost:4000/graphql' }) ;(async () => { await new Promise<void>((resolve, reject) => { client.subscribe( { query: 'subscription { onNewPost { id title } }' }, { next: (data) => console.log('Evento:', data), error: reject, complete: resolve } ) }) })() |
Explicación: Cliente minimal de graphql-ws que imprime eventos onNewPost.
Checklist:
- [ ] WS server activo
- [ ] PubSub inyectado
- [ ] Cliente de prueba funcionando
8) Caching y rendimiento (Redis, cache control, límites)
Resumen: Añadiremos cache de respuesta y un ejemplo simple con Redis para queries costosas y invalidación tras mutaciones.
Ejemplo de cache en resolver con Redis (opcional):
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// src/utils/cache.ts import Redis from 'ioredis' const redis = process.env.REDIS_URL ? new Redis(process.env.REDIS_URL) : null export async function cached<T>(key: string, ttlSec: number, fn: () => Promise<T>): Promise<T> { if (!redis) return fn() const hit = await redis.get(key) if (hit) return JSON.parse(hit) const val = await fn() await redis.set(key, JSON.stringify(val), 'EX', ttlSec) return val } export async function invalidate(key: string) { if (redis) await redis.del(key) } |
Explicación: Cache genérico con ioredis e invalidación básica.
Uso en Query.posts:
1 2 3 4 5 6 |
// dentro de Query.posts const cacheKey = `posts:first=${take}:after=${after ?? 'null'}` return cached(cacheKey, 30, async () => { // ... la misma lógica de búsqueda y retorno }) |
Explicación: Cachea listas por 30 segundos; invalida tras createPost si aplica.
Checklist:
- [ ] Cache de respuesta habilitado
- [ ] Redis opcional integrado
- [ ] Límites de page size y ordenación establecidos
9) Uploads y archivos (URLs firmadas S3, seguro)
Resumen: Recomendado usar URLs firmadas con S3 para evitar transportar binarios por GraphQL. Creamos una mutation que devuelve una URL firmada.
Schema (añadir):
1 2 3 4 |
extend type Mutation { getUploadUrl(filename: String!, contentType: String!): String! } |
Resolver (pseudo-implementación con AWS SDK v3):
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// src/modules/files/resolvers.ts import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' const s3 = new S3Client({ region: process.env.AWS_REGION }) export const FileMutation = { getUploadUrl: async (_: any, { filename, contentType }: any) => { const Key = `uploads/${Date.now()}-${filename}` const cmd = new PutObjectCommand({ Bucket: process.env.S3_BUCKET!, Key, ContentType: contentType }) return getSignedUrl(s3, cmd, { expiresIn: 60 }) } } |
Explicación: Devuelve URL firmada temporal; el cliente sube directo a S3.
Checklist:
- [ ] Evitar subir binarios por GraphQL
- [ ] Validar content-type y tamaño en el cliente/servidor
10) Testing y calidad (Jest + E2E)
Resumen: Añadiremos Jest y una prueba E2E simple contra el endpoint GraphQL.
jest.config.ts:
1 2 3 4 5 6 7 8 |
import type { Config } from 'jest' const config: Config = { preset: 'ts-jest', testEnvironment: 'node', testMatch: ['**/tests/**/*.ts'] } export default config |
Explicación: Configuración mínima para TypeScript.
tests/example.e2e.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import request from 'supertest' import http from 'http' import appFactory from '../src/test-server' let server: http.Server beforeAll(async () => { server = await appFactory() }) afterAll(async () => { server.close() }) test('login y query posts', async () => { const agent = request(server) const loginRes = await agent.post('/graphql').send({ query: `mutation { login(input: {email: "alice@example.com", password: "password123"}) { accessToken } }` }) expect(loginRes.status).toBe(200) const token = loginRes.body.data.login.accessToken const postsRes = await agent.post('/graphql').set('Authorization', `Bearer ${token}`).send({ query: `{ posts(first: 2) { edges { node { id title } } } }` }) expect(postsRes.body.data.posts.edges.length).toBeGreaterThanOrEqual(1) }) |
Explicación: Prueba de extremo a extremo: login y consulta de posts autenticada.
Servidor de prueba (src/test-server.ts):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
import 'dotenv/config' import express from 'express' import http from 'http' import bodyParser from 'body-parser' import { ApolloServer } from '@apollo/server' import { expressMiddleware } from '@apollo/server/express4' import { makeExecutableSchema } from '@graphql-tools/schema' import { readFileSync } from 'fs' import { join } from 'path' import { Query } from './resolvers/query' import { Mutation } from './resolvers/mutation' import { Subscription } from './resolvers/subscription' import { User, Post } from './resolvers/user' import { buildContext } from './context' export default async function appFactory() { const app = express() const server = http.createServer(app) const typeDefs = readFileSync(join(__dirname, 'schema.graphql'), 'utf8') const resolvers = { Query, Mutation, Subscription, User, Post } const schema = makeExecutableSchema({ typeDefs, resolvers }) const apollo = new ApolloServer({ schema }) await apollo.start() app.use('/graphql', bodyParser.json(), expressMiddleware(apollo, { context: (ctx) => ({ ...buildContext(ctx) }) as any })) return server.listen(0) } |
Explicación: Servidor simplificado para pruebas, sin WS ni plugins.
Checklist:
- [ ] Jest configurado
- [ ] Pruebas E2E mínimas
- [ ] Seed ejecutado antes de tests (si aplica)
11) Observabilidad y métricas (Pino, OpenTelemetry, Prometheus)
Resumen: Ya incluimos Pino y Prometheus. Opcionalmente añade OpenTelemetry para tracing distribuido.
- Pino: usado en context para logs estructurados.
- Prometheus: endpoint /metrics con prom-client.
- OpenTelemetry: integrar @opentelemetry/api y SDK es opcional por brevedad.
Checklist:
- [ ] Logs estructurados
- [ ] /metrics expuesto
- [ ] Health/readiness endpoints activos
12) Dockerización y orquestación local
Resumen: Crearemos Dockerfile multi-stage y docker-compose con API, Postgres y Redis.
Dockerfile:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# Builder FROM node:18-alpine AS builder WORKDIR /app COPY package.json pnpm-lock.yaml* package-lock.json* yarn.lock* ./ RUN npm i -g pnpm && pnpm install --frozen-lockfile || pnpm install COPY . . RUN pnpm prisma:generate && pnpm build # Runtime FROM node:18-alpine AS runtime WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist COPY --from=builder /app/src/schema.graphql ./dist/schema.graphql COPY --from=builder /app/package.json ./package.json CMD ["node", "dist/index.js"] |
Explicación: Compila en una etapa y ejecuta en otra, con Prisma Client generada y schema.graphql copiado.
docker-compose.yml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
version: '3.9' services: db: image: postgres:15-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: app ports: ["5432:5432"] volumes: ["pgdata:/var/lib/postgresql/data"] redis: image: redis:7-alpine ports: ["6379:6379"] api: build: . environment: DATABASE_URL: postgresql://postgres:postgres@db:5432/app?schema=public PORT: 4000 NODE_ENV: production JWT_ACCESS_SECRET: replace_me_access JWT_REFRESH_SECRET: replace_me_refresh CORS_ORIGIN: http://localhost:3000 ENABLE_METRICS: "true" REDIS_URL: redis://redis:6379 ports: ["4000:4000"] depends_on: - db - redis command: ["sh", "-c", "node -e \"(async()=>{try{require('child_process').execSync('npx prisma migrate deploy',{stdio:'inherit'})}catch(e){} })()\" && node dist/index.js"] volumes: pgdata: |
Explicación: Compose con servicios DB, Redis y API. La API aplica migraciones en arranque.
Comandos:
1 2 3 |
docker compose up -d --build # Espera a que arranque y visita http://localhost:4000/healthz |
Explicación: Levanta toda la pila localmente.
Checklist:
- [ ] Imagen multi-stage creada
- [ ] Compose con DB y Redis
- [ ] Migraciones en arranque
13) CI/CD básico (GitHub Actions + Docker)
Resumen: Pipeline con install, lint, test, build, push de imagen y migrate deploy en entorno de staging/prod.
.github/workflows/ci.yml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
name: CI on: [push, pull_request] jobs: build-test: runs-on: ubuntu-latest services: postgres: image: postgres:15-alpine env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: app ports: ['5432:5432'] options: >- --health-cmd="pg_isready -U postgres" --health-interval=10s --health-timeout=5s --health-retries=5 env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/app?schema=public steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 18 } - run: npm i -g pnpm - run: pnpm install --frozen-lockfile - run: npx prisma generate - run: npx prisma migrate deploy - run: pnpm test docker: needs: build-test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: push: true tags: ghcr.io/${{ github.repository }}:latest |
Explicación: Ejecuta tests con Postgres de servicio, luego construye y publica la imagen en GHCR.
Checklist:
- [ ] Workflow con tests/migraciones
- [ ] Build y push de imagen
14) Seguridad y mejores prácticas (rate limit, CORS, complexity)
Resumen: Añadimos rate limit, CORS seguro, helmet y límite de complejidad para GraphQL.
Límite de complejidad (ejemplo rápido):
1 2 3 4 5 6 7 8 9 10 |
// añadir en index.ts tras schema import { getComplexity, simpleEstimator, fieldExtensionsEstimator } from 'graphql-query-complexity' import { GraphQLError } from 'graphql' // middleware simple para /graphql app.use('/graphql', (req, res, next) => { // parseo más fino requiere integrar con Apollo plugin; aquí ejemplo conceptual next() }) |
Explicación: Para producción usa graphql-query-complexity como plugin en la ejecución para rechazar consultas demasiado costosas.
Otras recomendaciones:
- Deshabilita introspection en prod si usas consultas persistentes.
- Usa persisted queries/CDN para cachear GETs.
- Rota claves JWT periódicamente.
Checklist:
- [ ] Rate limiting activo
- [ ] CORS restringido
- [ ] Complejidad/depth limitado
15) Cómo probar: curl/HTTPie y ejemplos GraphQL
Resumen: Ejecuta la API y prueba queries, mutations y subscriptions desde terminal.
Arranque local:
1 2 3 |
pnpm dev # o en Docker: docker compose up -d api |
Queries/mutations:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# Login curl -s -X POST http://localhost:4000/graphql \ -H 'Content-Type: application/json' \ -d '{"query":"mutation($email:String!,$password:String!){ login(input:{email:$email,password:$password}){ accessToken refreshToken }}","variables":{"email":"alice@example.com","password":"password123"}}' # Guardar token TOKEN=$(curl -s -X POST http://localhost:4000/graphql -H 'Content-Type: application/json' -d '{"query":"mutation{ login(input:{email:\"alice@example.com\", password:\"password123\"}){ accessToken }}"}' | jq -r '.data.login.accessToken') # Obtener posts http POST :4000/graphql Authorization:"Bearer $TOKEN" query='{ posts(first:5){ edges{ node{ id title author{ email } } } pageInfo{ hasNextPage } } }' # Crear post http POST :4000/graphql Authorization:"Bearer $TOKEN" query='mutation{ createPost(input:{title:"Nuevo", content:"Contenido"}){ id title } }' |
Explicación: Ejemplos con curl/HTTPie para autenticar, consultar y crear.
Subscription (cliente script ya mostrado) o con wscat enviando JSON con protocolo graphql-ws.
Checklist:
- [ ] Puedes autenticarte y consultar datos
- [ ] Subscription recibe eventos al crear posts
16) Despliegue y escalado
Resumen: Consideraciones para producción y alta disponibilidad.
- Stateless servers: escala varias réplicas; usa Redis para PubSub en subscriptions (graphql-redis-subscriptions).
- Prisma en serverless: usa Prisma Accelerate/Data Proxy para pools eficientes.
- Pool sizing: ajusta conexiones según DB (p.ej. 10–20 por instancia).
- Balanceo y health probes: usa /healthz y /readyz.
- CDN y persisted queries: mejoran latencia y cacheabilidad.
Checklist:
- [ ] Servidores sin estado
- [ ] PubSub compartido (Redis) si usas subscriptions
- [ ] Conexiones de DB dimensionadas
17) Resolución de problemas comunes
Resumen: Errores típicos y soluciones rápidas.
- Prisma migrate falla en CI: asegura npx prisma generate y migrate deploy antes de iniciar server; revisa DATABASE_URL.
- CORS bloquea Playground/WS: ajusta CORS_ORIGIN y verifica que WS usa la misma ruta /graphql y host permitido.
- JWT expirado: captura GraphQLError, refresca con refreshToken mutation y reintenta.
- N+1 problem: confirma que resolvers de campos usan DataLoader.
- Prisma Client no generado en CI: ejecuta npx prisma generate tras pnpm install.
- Docker no conecta a DB: usa el host del servicio (db) y la red de Compose; no uses localhost dentro del contenedor.
- Queries lentas: añade índices (ya tenemos en authorId/postId), limita page size y usa EXPLAIN en consultas pesadas.
18) README (pasos rápidos para clonar y correr)
README sugerido:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# GraphQL Apollo Server + Prisma + PostgreSQL ## Requisitos - Node 18+ - Docker y Docker Compose ## Pasos 1. git clone <repo> && cd <repo> 2. cp .env.example .env 3. docker compose up -d db redis 4. pnpm install 5. pnpm prisma:generate && pnpm prisma:migrate && pnpm prisma:seed 6. pnpm dev 7. Abrir http://localhost:4000/graphql ## Testing pnpm test |
Explicación: Guía mínima para nuevos contribuidores.
19) FAQ (respuestas cortas)
- ¿GraphQL es más lento que REST? No necesariamente; con DataLoader, paginación y cache, puedes igualar o mejorar.
- ¿Cómo protejo la API? Rate limiting, CORS, helmet, límites de complejidad y JWT sólidos con rotación.
- ¿Puedo usar subscriptions en producción? Sí, con Redis PubSub o Kafka y servidores sin estado.
- ¿Prisma soporta transacciones? Sí, usa prisma.$transaction.
- ¿Cómo hago uploads? Preferible URLs firmadas a un bucket (S3, GCS).
Meta title, description y slug
- Meta title: Tutorial GraphQL Node.js TypeScript: Apollo Server, Prisma y PostgreSQL en producción
- Meta description: Guía paso a paso para crear una API GraphQL de producción con Node.js 18+, Apollo Server, TypeScript, Prisma y PostgreSQL. Incluye JWT, DataLoader, subscriptions, Docker y CI/CD.
- URL slug: tutorial-graphql-nodejs-typescript-apollo-prisma-postgresql
Checklist global para producción
- [ ] Variables de entorno seguras (JWT secrets, DATABASE_URL, CORS)
- [ ] Migraciones aplicadas y seed opcional ejecutado
- [ ] Logs estructurados y métricas /metrics disponibles
- [ ] Rate limiting, helmet y CORS configurados
- [ ] Límite de complejidad/depth en GraphQL
- [ ] DataLoader implementado y probado (sin N+1)
- [ ] Subscriptions con Redis si se escala horizontalmente
- [ ] Dockerfile multi-stage y compose listos
- [ ] CI con tests, build y push de imagen
- [ ] Monitorización de salud (/healthz, /readyz) y alertas