Principios de Estabilidad
La base filosófica que guía cada decisión de diseño
"Con suficientes usuarios de una API, todos los comportamientos observables del sistema serán dependencia de alguien, sin importar lo que prometa el contrato."
— Hyrum's Law · La ley que CultivaCRM violó dos veces en tres meses
01 · Contract-First
Define antes de implementar
Los tipos TypeScript son la spec. El código implementa la spec, nunca al revés. Si no hay interface definida, no hay implementación.
02 · One-Version Rule
Una sola versión viva
Extender, no bifurcar. Dos versiones multiplican el coste de mantenimiento y crean problemas de dependencias en diamante.
03 · Validar en Fronteras
Confianza interna, validación externa
Solo en route handlers (input usuario) y al parsear respuestas de terceros. Nunca entre funciones internas que comparten tipos.
04 · Adición, no Modificación
Solo campos opcionales nuevos
Añadir campos opcionales: siempre seguro. Eliminar campos, cambiar tipos o renombrar: breaking change. Diseña para extender desde el día 1.
TypeScript Strict
REST Semántico
Zod Validation
Branded Types
Cursor Pagination
Discriminated Unions
Contratos TypeScript — Campaign API
Los tipos son la documentación. Definidos antes que el código.
Branded IDs — Imposible confundir CampaignId con AdId
TypeScript
types/ids.ts
// Branded types — el compilador rechaza mezclas de IDs export type CampaignId = string & { readonly __brand: 'CampaignId' }; export type AdId = string & { readonly __brand: 'AdId' }; export type ClientId = string & { readonly __brand: 'ClientId' }; // Helpers de casting seguro export const toCampaignId = (s: string): CampaignId => s as CampaignId; export const toAdId = (s: string): AdId => s as AdId; // Error de compilación si mezclas — previene bugs en producción // getCampaign(adId) ← TypeScript error ✓
Estado de Campaña — Discriminated Union
TypeScript
types/campaign.ts
// Discriminated union — cada estado lleva sus propios campos export type CampaignStatus = | { type: 'draft' } | { type: 'scheduled'; scheduledAt: Date } | { type: 'active'; startedAt: Date; budget: number } | { type: 'paused'; pausedAt: Date; reason: string } | { type: 'completed'; endedAt: Date; totalSpend: number } | { type: 'cancelled'; cancelledAt: Date; by: UserId }; // Input (lo que el cliente envía) export interface CreateCampaignInput { name: string; clientId: ClientId; platform: 'META' | 'GOOGLE' | 'TIKTOK' | 'LINKEDIN'; budget: number; // EUR, centavos objective: 'AWARENESS' | 'LEADS' | 'CONVERSIONS'; scheduledAt?: Date; // opcional — puede ser inmediata labels?: string[]; // añadido en v1.1, sigue opcional } // Output (lo que el servidor devuelve — incluye campos generados) export interface Campaign { id: CampaignId; name: string; clientId: ClientId; platform: 'META' | 'GOOGLE' | 'TIKTOK' | 'LINKEDIN'; budget: number; objective: 'AWARENESS' | 'LEADS' | 'CONVERSIONS'; status: CampaignStatus; labels: string[]; createdAt: Date; updatedAt: Date; createdBy: UserId; }
Interfaz de la API — Contract-First
TypeScript
contracts/CampaignAPI.ts
// Este archivo define el contrato. El servidor lo implementa. // El cliente (React / SDK / Zapier) lo consume. Nunca al revés. export interface CampaignAPI { /** Crea campaña y devuelve el objeto completo con ID generado */ createCampaign(input: CreateCampaignInput): Promise<Campaign>; /** Lista campañas con cursor-based pagination */ listCampaigns(params: ListCampaignsParams): Promise<CursorPage<Campaign>>; /** Devuelve campaña o lanza NotFoundError */ getCampaign(id: CampaignId): Promise<Campaign>; /** Actualización parcial — solo cambian los campos proporcionados */ updateCampaign(id: CampaignId, input: UpdateCampaignInput): Promise<Campaign>; /** Delete idempotente — éxito aunque ya esté eliminada */ deleteCampaign(id: CampaignId): Promise<void>; /** Sub-recurso: anuncios de una campaña */ listAds(campaignId: CampaignId, params: ListAdsParams): Promise<CursorPage<Ad>>; createAd(campaignId: CampaignId, input: CreateAdInput): Promise<Ad>; /** Métricas agregadas — solo lectura */ getMetrics(campaignId: CampaignId, params: MetricsParams): Promise<CampaignMetrics>; }
Mapa de Endpoints REST
Naming consistente: sustantivos plurales, sin verbos en la URL
GET
/api/v1/campaigns
Listar campañas (cursor paginado)
POST
/api/v1/campaigns
Crear nueva campaña → 201
GET
/api/v1/campaigns/:campaignId
Obtener campaña por ID
PATCH
/api/v1/campaigns/:campaignId
Actualización parcial (solo campos enviados)
DELETE
/api/v1/campaigns/:campaignId
Eliminar (idempotente) → 204
GET
/api/v1/campaigns/:campaignId/ads
Listar anuncios de campaña
POST
/api/v1/campaigns/:campaignId/ads
Añadir anuncio a campaña
PATCH
/api/v1/campaigns/:campaignId/ads/:adId
Editar anuncio específico
GET
/api/v1/campaigns/:campaignId/metrics
Métricas agregadas (solo lectura)
Cursor Pagination
Para volumen alto y tiempo real
TypeScript
types/pagination.ts
// Request interface ListCampaignsParams { cursor?: string; // opaque limit?: number; // default 20, max 100 status?: CampaignStatus['type']; platform?: string; clientId?: ClientId; sortBy?: 'createdAt' | 'updatedAt' | 'budget'; sortOrder?:'asc' | 'desc'; } // Response shape interface CursorPage<T> { data: T[]; nextCursor: string | null; hasMore: boolean; totalCount?: number; // caro, optional }
Cursor opaco (base64 de {id + createdAt}). No depende del offset — no se rompe con inserciones concurrentes.
Validación en Frontera
Solo en route handlers, con Zod
TypeScript
routes/campaigns.ts
const CreateCampaignSchema = z.object({ name: z.string().min(1).max(120), clientId: z.string().uuid(), platform: z.enum(['META','GOOGLE','TIKTOK','LINKEDIN']), budget: z.number().int().positive(), objective:z.enum(['AWARENESS','LEADS','CONVERSIONS']), scheduledAt:z.date().optional(), labels: z.array(z.string()).optional(), }); app.post('/api/v1/campaigns', async (req, res) => { const result = CreateCampaignSchema.safeParse(req.body); if (!result.success) { return res.status(422).json({ error: { code: 'VALIDATION_ERROR', message: 'Datos de campaña inválidos', details: result.error.flatten(), } }); } // A partir de aquí el código interno confía en los tipos const campaign = await campaignService.create(result.data); return res.status(201).json(campaign); });
Errores Consistentes — Un único formato en toda la API
El problema del CultivaCRM anterior: 3 formatos distintos de error según el endpoint
TypeScript
types/errors.ts
// SIEMPRE esta forma, sin excepciones interface APIError { error: { code: string; // SNAKE_UPPER legible por máquina message: string; // Texto para el humano details?: unknown; // Contexto extra si aplica }; }
JSON
Ejemplo respuesta 422
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Datos de campaña inválidos",
"details": {
"fieldErrors": {
"budget": ["Expected positive integer"],
"platform": ["Invalid enum value"]
}
}
}
}
| HTTP Status | Error Code | Cuándo usarlo |
|---|---|---|
| 400 | BAD_REQUEST | El cliente envió datos malformados (JSON inválido, parámetros imposibles) |
| 401 | UNAUTHENTICATED | No hay token de sesión o está expirado |
| 403 | FORBIDDEN | Autenticado pero sin permiso para este recurso (campaña de otro cliente) |
| 404 | NOT_FOUND | La campaña con ese ID no existe o fue eliminada |
| 409 | CONFLICT | Nombre de campaña duplicado para ese cliente, o conflicto de versión |
| 422 | VALIDATION_ERROR | JSON válido pero semánticamente incorrecto (budget negativo, fecha pasada) |
| 500 | INTERNAL_ERROR | Error del servidor — nunca exponer stack trace ni detalles internos |
Red Flags vs. Corrección
Lo que había antes vs. lo que hay ahora
Antes — Roto
/api/createCampaign
Ahora — Correcto
POST /api/v1/campaigns
Antes — Roto
{ success: true, data: {...} }
Ahora — Correcto
{ ...campaign } → 201
Antes — Roto
GET /api/campaigns sin paginación
Ahora — Correcto
?cursor=&limit=20
Antes — Roto
status: "InProgress" o "in-progress"
Ahora — Correcto
status.type: "active" (enum fijo)
Antes — Roto
Response de tercero sin validar
Ahora — Correcto
z.parse() antes de usar datos externos
Checklist de Verificación
Antes de hacer merge de cualquier endpoint nuevo
-
Tipos Input/Output definidos CreateCampaignInput + Campaign exportados en contracts/
-
Errores en formato único APIError Ningún endpoint devuelve { success: false } ni string
-
Validación solo en route handler campaignService nunca valida input — confía en los tipos
-
Listas con cursor pagination CursorPage<T> en todos los listCampaigns / listAds
-
Nuevos campos = opcionales labels?: string[] — nunca campo requerido nuevo en v1
-
Naming conventions homogéneo camelCase en body, UPPER_SNAKE en enums, plural en URLs
-
Tipos junto al código contracts/ commiteado en el mismo PR que la implementación
CULTIVA IA · Skill:
diseno-apis-interfaces-estables · CultivaCRM SaaS — API v1.0 · Generado por agente