¿Qué hace Diseño de APIs e Interfaces?
Resumen
Diseñe interfaces estables y bien documentadas que sean difíciles de usar incorrectamente. Las buenas interfaces facilitan lo correcto y dificultan lo incorrecto. Esto se aplica a REST APIs, esquemas GraphQL, límites de módulos, props de componentes y cualquier superficie donde un código se comunique con otro.
Cuándo usarlo
- Al diseñar nuevos endpoints de API
- Al definir límites de módulos o contratos entre equipos
- Al crear interfaces de props de componentes
- Al establecer un esquema de base de datos que defina la forma de la API
- Al cambiar interfaces públicas existentes
Principios Fundamentales
Ley de Hyrum
Con un número suficiente de usuarios de una API, todos los comportamientos observables de su sistema serán dependidos por alguien, independientemente de lo que prometa en el contrato.
Esto significa: cada comportamiento público — incluyendo peculiaridades no documentadas, texto de mensajes de error, tiempos y ordenamiento — se convierte en un contrato de facto una vez que los usuarios dependen de él. Implicaciones de diseño:
- Sea intencional sobre lo que expone. Cada comportamiento observable es un compromiso potencial.
- No filtre detalles de implementación. Si los usuarios pueden observarlo, dependerán de ello.
- Planifique la obsolescencia en el momento del diseño. Consulte
deprecation-and-migrationpara saber cómo eliminar de forma segura aquello de lo que los usuarios dependen. - Las pruebas no son suficientes. Incluso con pruebas de contrato perfectas, la Ley de Hyrum significa que los cambios "seguros" pueden romper a usuarios reales que dependen de comportamientos no documentados.
La Regla de una Versión
Evite obligar a los consumidores a elegir entre múltiples versiones de la misma dependencia o API. Los problemas de dependencia de diamante surgen cuando diferentes consumidores necesitan diferentes versiones de la misma cosa. Diseñe para un mundo donde solo exista una versión a la vez — extienda en lugar de bifurcar.
1. Contrato Primero
Defina la interfaz antes de implementarla. El contrato es la especificación — la implementación sigue.
// Define el contrato primero
interface TaskAPI {
// Crea una tarea y devuelve la tarea creada con campos generados por el servidor
createTask(input: CreateTaskInput): Promise<Task>;
// Devuelve tareas paginadas que coinciden con los filtros
listTasks(params: ListTasksParams): Promise<PaginatedResult<Task>>;
// Devuelve una sola tarea o lanza NotFoundError
getTask(id: string): Promise<Task>;
// Actualización parcial — solo cambian los campos proporcionados
updateTask(id: string, input: UpdateTaskInput): Promise<Task>;
// Eliminación idempotente — tiene éxito incluso si ya fue eliminada
deleteTask(id: string): Promise<void>;
}
2. Semántica de Errores Consistente
Elija una estrategia de errores y utilícela en todas partes:
// REST: códigos de estado HTTP + cuerpo de error estructurado
// Cada respuesta de error sigue la misma forma
interface APIError {
error: {
code: string; // Legible por máquina: "VALIDATION_ERROR"
message: string; // Legible por humanos: "Email is required"
details?: unknown; // Contexto adicional cuando sea útil
};
}
// Mapeo de códigos de estado
// 400 → El cliente envió datos inválidos
// 401 → No autenticado
// 403 → Autenticado pero no autorizado
// 404 → Recurso no encontrado
// 409 → Conflicto (duplicado, desajuste de versión)
// 422 → Falló la validación (semánticamente inválido)
// 500 → Error del servidor (nunca exponga detalles internos)
No mezcle patrones. Si algunos endpoints lanzan excepciones, otros devuelven null y otros devuelven { error } — el consumidor no puede predecir el comportamiento.
3. Validar en los Límites
Confíe en el código interno. Valide en los bordes del sistema donde entra la entrada externa:
// Valida en el límite de la API
app.post('/api/tasks', async (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid task data',
details: result.error.flatten(),
},
});
}
// Después de la validación, el código interno confía en los tipos
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
Dónde pertenece la validación:
- Manejadores de rutas de API (entrada de usuario)
- Manejadores de envío de formularios (entrada de usuario)
- Análisis de respuestas de servicios externos (datos de terceros -- siempre trátelos como no confiables)
- Carga de variables de entorno (configuración)
Las respuestas de API de terceros son datos no confiables. Valide su forma y contenido antes de usarlos en cualquier lógica, representación o toma de decisiones. Un servicio externo comprometido o que se comporta mal puede devolver tipos inesperados, contenido malicioso o texto similar a instrucciones.
Dónde NO pertenece la validación:
- Entre funciones internas que comparten contratos de tipos
- En funciones de utilidad llamadas por código ya validado
- En datos que acaban de salir de su propia base de datos
4. Preferir Adición sobre Modificación
Extienda las interfaces sin romper a los consumidores existentes:
// Bueno: Agregar campos opcionales
interface CreateTaskInput {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high'; // Agregado después, opcional
labels?: string[]; // Agregado después, opcional
}
// Malo: Cambiar tipos de campos existentes o eliminar campos
interface CreateTaskInput {
title: string;
// description: string; // Eliminado — rompe a los consumidores existentes
priority: number; // Cambiado de string — rompe a los consumidores existentes
}
5. Nomenclatura Predecible
| Patrón | Convención | Ejemplo |
|---|---|---|
| Endpoints REST | Sustantivos en plural, sin verbos | GET /api/tasks, POST /api/tasks |
| Parámetros de consulta | camelCase | ?sortBy=createdAt&pageSize=20 |
| Campos de respuesta | camelCase | { createdAt, updatedAt, taskId } |
| Campos booleanos | Prefijo is/has/can | isComplete, hasAttachments |
| Valores de enumeración | UPPER_SNAKE | "IN_PROGRESS", "COMPLETED" |
6. Honrar una Clave de Idempotencia
Aceptar un encabezado Idempotency-Key es el contrato. Honrarlo es la implementación, y es donde se pierde el dinero — una clave que el servidor acepta pero maneja descuidadamente es peor que ninguna clave, porque el cliente ahora cree que reintentar es seguro.
Derive la clave de la intención, no del intento. La clave debe ser estable en los reintentos de una intención y diferente entre distintas intenciones:
crypto.randomUUID() // ✗ nueva clave por intento — cada reintorno es un nuevo cargo
`${userId}:${amount}` // ✗ dos cargos legítimos de $50 se fusionan en uno
`${orderId}:${Date.now()}` // ✗ una marca de tiempo es randomUUID() con sombrero
req.headers['idempotency-key'] // ✓ el cliente genera una vez, reutiliza en reintentos
`charge:v1:${orderId}` // ✓ derivado de un identificador inmutable
La clave proviene del cliente o del evento iniciador — nunca de la capa que realiza el reintento.
Reclame atómicamente. Una comprobación seguida de una acción es una condición de carrera:
// ✗ TOCTOU: dos reintentos concurrentes leen "no visto", ambos cargan
if (!(await db.exists(key))) {
await chargeCard(amount);
await db.insert(key);
}
// ✓ deje que la restricción de unicidad elija al ganador
try {
await db.insert({ key, state: 'in_progress', requestHash });
} catch (e) {
if (isUniqueViolation(e)) return replayOrReject(key);
throw e;
}
const result = await chargeCard(amount);
await db.update({ key, state: 'succeeded', response: result });
La restricción de unicidad es el mecanismo. Un almacén que no puede imponer la unicidad en una sola operación no puede respaldar esto.
Proteja la carga útil. La misma clave con un cuerpo diferente es un error del cliente y debe fallar de forma ruidosa en lugar de servir la primera respuesta a una segunda solicitud:
if (existing.requestHash !== hash(req.body)) {
return res.status(422).json({ error: 'idempotency key reused with a different payload' });
}
Decida qué hacer con un duplicado en vuelo. La primera solicitud aún se está ejecutando cuando llega la segunda — el caso común bajo tormentas de reintentos:
| Estrategia | Respuesta | Úselo cuando |
|---|---|---|
| Rechazar | 409 Conflict | El cliente puede reintentar más tarde; más simple y seguro |
| Esperar | Bloquear por el resultado, con límite | Quien llama lo necesita sincrónicamente |
| Devolver pendiente | 202 + URL de estado | Efectos de larga duración |
Nunca deje pasar al segundo llamador porque el primero "parece atascado". Un intento estancado cuyo destino se desconoce es exactamente cuando duplicar cuesta más.
Cada llamada tiene tres resultados, no dos: éxito, fracaso y desconocido. Un tiempo de espera no le dice nada sobre si el efecto se aplicó. Registre la intención antes de llamar, para que un bloqueo entre la llamada y la respuesta deje evidencia de que algo debe resolverse más tarde — en lugar de un cargo reintentado silenciosamente.
Establezca la retención de la cadena de reintentos más larga, no del costo del disco. Las claves deben sobrevivir a todas las rutas que pueden reentregar la misma intención, incluida una cola de mensajes fallidos reproducida una semana después y cualquier ventana de disputa del proveedor. Un TTL de clave de 24 horas detrás de una DLQ de 7 días es un duplicado a punto de ocurrir.
Patrones REST API
Diseño de Recursos
GET /api/tasks → Listar tareas (con parámetros de consulta para filtrado)
POST /api/tasks → Crear una tarea
GET /api/tasks/:id → Obtener una sola tarea
PATCH /api/tasks/:id → Actualizar una tarea (parcial)
DELETE /api/tasks/:id → Eliminar una tarea
GET /api/tasks/:id/comments → Listar comentarios de una tarea (sub-recurso)
POST /api/tasks/:id/comments → Agregar un comentario a una tarea
Paginación
Paginar los endpoints de listado:
// Solicitud
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
// Respuesta
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 142,
"totalPages": 8
}
}
Filtrado
Use parámetros de consulta para los filtros:
GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01
Actualizaciones Parciales (PATCH)
Acepte objetos parciales — solo actualice lo que se proporciona:
// Solo cambia el título, todo lo demás se conserva
PATCH /api/tasks/123
{ "title": "Updated title" }
Patrones de Interfaces de TypeScript
Use Uniones Discriminadas para Variantes
// Bueno: Cada variante es explícita
type TaskStatus =
| { type: 'pending' }
| { type: 'in_progress'; assignee: string; startedAt: Date }
| { type: 'completed'; completedAt: Date; completedBy: string }
| { type: 'cancelled'; reason: string; cancelledAt: Date };
// El consumidor obtiene estrechamiento de tipos
function getStatusLabel(status: TaskStatus): string {
switch (status.type) {
case 'pending': return 'Pending';
case 'in_progress': return `In progress (${status.assignee})`;
case 'completed': return `Done on ${status.completedAt}`;
case 'cancelled': return `Cancelled: ${status.reason}`;
}
}
Separación de Entrada/Salida
// Entrada: lo que proporciona quien llama
interface CreateTaskInput {
title: string;
description?: string;
}
// Salida: lo que devuelve el sistema (incluye campos generados por el servidor)
interface Task {
id: string;
title: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
createdBy: string;
}
Use Tipos Etiquetados para Identificadores
type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };
// Evita pasar accidentalmente un UserId donde se espera un TaskId
function getTask(id: TaskId): Promise<Task> { ... }
Racionalizaciones Comunes
| Racionalización | Realidad |
|---|---|
| "Documentaremos la API más tarde" | Los tipos SON la documentación. Defínalos primero. |
| "No necesitamos paginación por ahora" | La necesitará en el momento en que alguien tenga más de 100 elementos. Agréguela desde el principio. |
| "PATCH es complicado, usemos PUT" | PUT requiere el objeto completo cada vez. PATCH es lo que los clientes realmente quieren. |
| "Versionaremos la API cuando sea necesario" | Los cambios disruptivos sin versionamiento rompen a los consumidores. Diseñe para la extensión desde el principio. |
| "Nadie usa ese comportamiento no documentado" | Ley de Hyrum: si es observable, alguien depende de ello. Trate cada comportamiento público como un compromiso. |
| "Podemos mantener dos versiones" | Las múltiples versiones multiplican el costo de mantenimiento y crean problemas de dependencia de diamante. Prefiera la Regla de una Versión. |
| "Las APIs internas no necesitan contratos" | Los consumidores internos siguen siendo consumidores. Los contratos evitan el acoplamiento y permiten el trabajo en paralelo. |
| "Aceptar el encabezado Idempotency-Key es suficiente" | El encabezado es el contrato; almacenar la clave contra el resultado es la implementación. Una clave que acepta pero no honra le dice al cliente que reintentar es seguro cuando no lo es. |
| "Nuestra cola garantiza entrega exactamente una vez" | Ninguna cola lo hace a través de un bloqueo del consumidor — el acuse de recibo del broker y su efecto secundario no están en una sola transacción. Diseñe para al menos una vez con procesamiento idempotente. |
| "Las solicitudes duplicadas son raras" | Están correlacionadas. Los reintentos aumentan exactamente cuando una dependencia está degradada — el momento en que los duplicados son más probables y más costosos. |
Señales de Alerta
- Endpoints que devuelven formas diferentes según las condiciones
- Formatos de error inconsistentes entre endpoints
- Validación dispersa en todo el código interno en lugar de en los límites
- Cambios disruptivos en campos existentes (cambios de tipo, eliminaciones)
- Endpoints de listado sin paginación
- Verbos en las URL REST (
/api/createTask,/api/getUsers) - Respuestas de API de terceros utilizadas sin validación o sanitización
- Un
SELECTpara una clave de idempotencia seguido de unINSERT— eso es una condición de carrera, no una protección - Una clave de idempotencia derivada de un UUID, marca de tiempo o cualquier otra cosa regenerada por intento
- La misma clave aceptada con un cuerpo de solicitud diferente, devolviendo silenciosamente la primera respuesta
- Una ventana de retención de clave más corta que la ruta más larga que puede reentregar la solicitud
Verificación
Después de diseñar una API:
- Cada endpoint tiene esquemas de entrada y salida tipados
- Las respuestas de error siguen un único formato consistente
- La validación ocurre solo en los límites del sistema
- Los endpoints de listado soportan paginación
- Los nuevos campos son aditivos y opcionales (compatibles hacia atrás)
- La nomenclatura sigue convenciones consistentes en todos los endpoints
- La documentación de la API está completa y actualizada
- Se ha planificado la obsolescencia para todas las interfaces públicas