Guía técnica

Diseno de APIs para sistemas que sobreviven a tu equipo

Como disenar APIs que sobrevivan a la rotacion de equipos. Protobuf como fuente de verdad, tRPC para stacks TypeScript, GraphQL en produccion, gestion de breaking changes y contratos de error.

5 de abril de 202614 min de lecturaEquipo de Ingeniería Oronts

La API que sobrevive a su creador

Cada API empieza con un equipo, un cliente y un caso de uso. Dos anos despues, 5 equipos dependen de ella, 3 socios externos se integran con ella, y los desarrolladores originales se fueron. Las decisiones de diseno de la API ahora son permanentes.

Hemos construido APIs con tres estrategias diferentes: Protobuf como fuente de verdad del schema para sistemas multi-lenguaje, tRPC para stacks 100% TypeScript, y GraphQL para necesidades complejas del cliente. Cada una fue la eleccion correcta en su contexto. Este articulo cubre cuando usar cada una y como disenar APIs que sobrevivan a la rotacion de equipos.

Para entender como estas APIs encajan en un diseno de sistema mas amplio, consulta nuestra guia de arquitectura de sistemas y nuestra guia de ingenieria de software.

Protobuf: fuente de verdad para sistemas multi-lenguaje

Cuando tu sistema abarca multiples lenguajes (API TypeScript, servicio de busqueda en Go, pipeline ML en Python), necesitas un formato de schema que genere tipos para todos ellos. Protobuf hace exactamente eso.

// proto/product.proto
syntax = "proto3";
package commerce.v1;

message Product {
    string id = 1;
    string name = 2;
    string description = 3;
    int32 price_cents = 4;
    string currency = 5;
    repeated string category_ids = 6;
    ProductStatus status = 7;
    google.protobuf.Timestamp created_at = 8;
}

enum ProductStatus {
    PRODUCT_STATUS_UNSPECIFIED = 0;
    PRODUCT_STATUS_DRAFT = 1;
    PRODUCT_STATUS_ACTIVE = 2;
    PRODUCT_STATUS_ARCHIVED = 3;
}

service ProductService {
    rpc GetProduct(GetProductRequest) returns (Product);
    rpc ListProducts(ListProductsRequest) returns (ListProductsResponse);
    rpc CreateProduct(CreateProductRequest) returns (Product);
}

Por que los tags importan para la evolucion

Los campos de Protobuf se identifican por numeros de tag (1, 2, 3...), no por nombre. Esto significa que puedes renombrar un campo sin romper clientes existentes. Puedes agregar nuevos campos (nuevos numeros de tag) sin romper clientes antiguos. Puedes depreciar campos sin eliminarlos.

// Evolucion segura: agregar un campo
message Product {
    string id = 1;
    string name = 2;
    string description = 3;
    int32 price_cents = 4;
    string currency = 5;
    repeated string category_ids = 6;
    ProductStatus status = 7;
    google.protobuf.Timestamp created_at = 8;
    string sku = 9;          // NUEVO: agregado sin romper clientes existentes
    string brand = 10;       // NUEVO: los clientes antiguos simplemente ignoran campos desconocidos
}

Reglas para una evolucion segura de Protobuf:

  • Nunca reutilices un numero de tag (ni siquiera despues de eliminar un campo)
  • Nunca cambies el tipo de un campo
  • Agrega nuevos campos con nuevos numeros de tag
  • Marca los campos depreciados con reserved para evitar la reutilizacion accidental

Generacion de codigo

# Genera TypeScript, Go y Python desde el mismo archivo .proto
protoc --ts_out=./gen/ts --go_out=./gen/go --python_out=./gen/py proto/*.proto

Un solo archivo de schema genera clientes y servidores type-safe en cada lenguaje. Los cambios de schema son un pull request. La generacion de codigo es un paso de CI. Las incompatibilidades de tipos son errores de compilacion, no bugs en runtime.

tRPC: cuando todo tu stack es TypeScript

Si tu servidor API y todos tus clientes son TypeScript, tRPC elimina la capa de schema por completo. Los tipos fluyen del servidor al cliente en tiempo de compilacion. Sin generacion de codigo, sin archivos de schema, sin spec OpenAPI.

// Servidor: definicion del router con procedimientos tipados
import { router, publicProcedure, protectedProcedure } from './trpc';
import { z } from 'zod';

export const productRouter = router({
    list: publicProcedure
        .input(z.object({
            cursor: z.string().optional(),
            limit: z.number().min(1).max(100).default(20),
            category: z.string().optional(),
        }))
        .query(async ({ input, ctx }) => {
            return ctx.productService.list(input);
        }),

    create: protectedProcedure
        .input(z.object({
            name: z.string().min(1).max(200),
            price: z.number().positive(),
            description: z.string().optional(),
        }))
        .mutation(async ({ input, ctx }) => {
            return ctx.productService.create(ctx.tenantId, input);
        }),
});

// Cliente: inferencia de tipos completa, sin generacion de codigo
const products = await trpc.product.list.query({
    limit: 10,
    category: 'electronics',
});
// products esta completamente tipado: { items: Product[], nextCursor?: string }

Cuando tRPC funciona mejor

  • Todos los clientes son TypeScript (app web, React Native, servicios Node.js)
  • La API es interna (no expuesta a terceros)
  • La iteracion rapida importa mas que los contratos formales de API
  • El equipo es pequeno y co-localizado (los cambios de servidor y cliente se hacen juntos)

Cuando tRPC no funciona

  • Socios externos necesitan integrarse (necesitan documentacion OpenAPI/Swagger)
  • Los clientes estan en otros lenguajes (mobile nativo, Go, Python)
  • Necesitas versionado de API para retrocompatibilidad
  • La API es un producto publico

Usamos tRPC con Hono para APIs TypeScript internas. Consulta nuestra guia de backends TypeScript para la comparacion completa de stacks.

GraphQL: para necesidades complejas del cliente

GraphQL brilla cuando los clientes necesitan queries flexibles: diferentes paginas necesitan diferentes subconjuntos de datos, mobile necesita menos datos que web, y el equipo frontend quiere iterar en queries sin cambios en el backend.

# El cliente solicita exactamente lo que necesita
query ProductPage($slug: String!) {
    product(slug: $slug) {
        id
        name
        price
        images { url alt }
        reviews(first: 5, status: APPROVED) {
            items { rating body customerName }
            totalItems
        }
        relatedProducts(first: 4) {
            id name price images { url }
        }
    }
}

Patrones de GraphQL en produccion

Queries persistidos: No permitas queries arbitrarios en produccion. Los clientes envian un hash del query, el servidor busca el query en un registro. Esto previene abusos de queries y habilita el caching.

Limitacion de profundidad: Sin limites, un cliente puede enviar un query profundamente anidado que hace joins con todas las tablas de tu base de datos.

apiOptions: {
    middleware: [depthLimit(10)],
    shopApiPlayground: false,  // Desactivado en produccion
}

Prevencion del N+1: Usa DataLoader para agrupar consultas a la base de datos. Sin esto, un query que obtiene 20 productos con sus categorias hace 20 consultas de categorias separadas.

// DataLoader agrupa N consultas individuales en 1
const categoryLoader = new DataLoader(async (ids: string[]) => {
    const categories = await categoryRepo.findByIds(ids);
    return ids.map(id => categories.find(c => c.id === id));
});

Analisis de complejidad: Asigna un costo a cada campo. Rechaza queries que excedan un presupuesto de complejidad.

Para ver como usamos GraphQL en Vendure commerce, consulta nuestra guia de produccion de Vendure.

Breaking changes: evolucion por tags vs versionado por URL

EstrategiaComo funcionaIdeal para
Por tags (Protobuf)Agrega campos con nuevos tags, los clientes antiguos los ignoranMulti-lenguaje, gRPC
Versionado por URL (/v1/, /v2/)Endpoints separados por versionAPIs REST con consumidores externos
Versionado por header (Accept: application/vnd.api.v2+json)Misma URL, version en el headerAPIs REST con URLs limpias
GraphQL (sin versionado)Agrega campos, deprecia los antiguos con @deprecatedAPIs GraphQL
tRPC (sin versionado)Los tipos evolucionan con la codebaseAPIs TypeScript internas

Para la mayoria de las APIs internas, evita el versionado por URL. Duplica tu superficie de mantenimiento. Agrega nuevos campos, deprecia los antiguos, eliminanos una vez que todos los clientes hayan migrado.

Para APIs externas (integraciones de terceros, APIs publicas), el versionado por URL es mas seguro porque no controlas cuando los clientes actualizan.

Contratos de error

Los errores son parte del contrato de la API. Los clientes necesitan errores estructurados sobre los que puedan actuar, no mensajes de texto que parsean con regex.

// Respuesta de error estructurada
interface ApiError {
    code: string;           // Legible por maquina: "PRODUCT_NOT_FOUND", "INSUFFICIENT_STOCK"
    message: string;        // Legible por humanos: "Product with ID xyz not found"
    details?: object;       // Contexto adicional para debugging
    requestId: string;      // ID de correlacion para soporte
}

// Ejemplos de respuestas
// 404
{
    "code": "PRODUCT_NOT_FOUND",
    "message": "Product with ID prod_123 not found",
    "requestId": "req_abc456"
}

// 409
{
    "code": "INSUFFICIENT_STOCK",
    "message": "Only 2 units available, requested 5",
    "details": { "available": 2, "requested": 5 },
    "requestId": "req_def789"
}

// 422
{
    "code": "VALIDATION_ERROR",
    "message": "Invalid input",
    "details": {
        "fields": [
            { "field": "price", "error": "must be positive" },
            { "field": "name", "error": "must not be empty" }
        ]
    },
    "requestId": "req_ghi012"
}

El campo code es el contrato. Los clientes hacen switch sobre el. El message es para humanos. Los details proporcionan contexto. El requestId permite al soporte encontrar la solicitud exacta en los logs.

Gobernanza de API

A medida que la API crece, necesitas gobernanza para prevenir inconsistencias:

ReglaPor que
Todos los endpoints requieren autenticacionSin endpoints publicos accidentales
Todas las mutaciones requieren claves de idempotenciaLos reintentos de red no crean duplicados
Todas las respuestas incluyen requestIdCada solicitud es rastreable
Todos los errores usan el formato estructuradoLos clientes pueden manejar errores de forma programatica
Todos los endpoints de lista soportan paginacionSin consultas no acotadas
Los breaking changes requieren reviewUna sola persona no puede romper todos los consumidores
Las depreciaciones requieren un calendario de migracion"Depreciado" sin fecha limite significa "nunca eliminado"

Errores comunes

  1. REST vs GraphQL como identidad. Elige segun las necesidades del cliente, no la preferencia del equipo. REST para CRUD simple con consumidores externos. GraphQL para queries flexibles con equipos frontend. tRPC para TypeScript interno. Protobuf para multi-lenguaje.

  2. Sin contrato de error. Mensajes de error de texto que cambian con cada release rompen cada cliente que intenta manejarlos.

  3. Endpoints de lista no acotados. Un endpoint que devuelve los 50,000 productos en una sola respuesta va a crashear clientes y sobrecargar tu base de datos. La paginacion es obligatoria.

  4. Versionar APIs internas. Si controlas todos los clientes, haz evolucionar la API in situ. El versionado duplica el mantenimiento sin beneficio.

  5. Sin calendario de depreciacion. Marcar un campo @deprecated sin fecha de eliminacion significa que se queda para siempre. Fija una fecha, notifica a los consumidores, eliminalo.

  6. GraphQL sin limites de profundidad. Una API GraphQL sin restricciones es un vector de denegacion de servicio. Limita la profundidad, la complejidad y el costo de los queries.

Puntos clave

  • Protobuf para sistemas multi-lenguaje. Evolucion por tags, generacion de codigo, eficiencia del formato wire. El schema es el contrato.

  • tRPC para stacks 100% TypeScript. Cero generacion de codigo, inferencia de tipos completa, maxima velocidad de iteracion. Pero solo funciona cuando todos los clientes son TypeScript.

  • GraphQL para necesidades flexibles del cliente. Los clientes consultan exactamente lo que necesitan. Pero agrega limites de profundidad, queries persistidos y analisis de complejidad para produccion.

  • Los contratos de error son tan importantes como los contratos de exito. Codigos legibles por maquina, mensajes legibles por humanos, IDs de correlacion y detalles estructurados.

  • Evoluciona in situ para APIs internas, versiona para externas. Agregar campos es seguro. Eliminar campos requiere un calendario de migracion. El versionado por URL es el ultimo recurso.

Disenamos APIs como parte de nuestras practicas de desarrollo web y software a medida. Si necesitas ayuda con la arquitectura de tus APIs, habla con nuestro equipo o solicita un presupuesto.

Temas cubiertos

diseno de APIversionado de APIestabilidad de APIbreaking changesAPI contract-firsttRPCGraphQL designProtobufgobernanza de API

¿Listo para construir sistemas de IA listos para producción?

Nuestro equipo se especializa en sistemas de IA listos para producción. Hablemos de cómo podemos ayudar.

Iniciar una conversación