Home / Backend / Diseño de sistemas: de un servidor a una arquitectura escalable
Diseño de sistemas: de un servidor a una arquitectura escalable

Diseño de sistemas: de un servidor a una arquitectura escalable

2 November 2026 · 22 min de lectura ··

Por qué el diseño de sistemas importa a cualquier desarrollador

Escribir código que funciona en una laptop es solo el primer paso. El verdadero reto aparece cuando esa aplicación debe atender a miles de usuarios simultáneos, mantenerse disponible aunque falle una máquina, responder en milisegundos y proteger los datos de quien intente abusar de ella. El diseño de sistemas es la disciplina que se ocupa de esas preguntas: cómo se reparten las responsabilidades entre componentes, cómo se comunican y qué ocurre cuando algo se rompe.

Durante mucho tiempo se consideró un tema reservado a arquitectos o a entrevistas técnicas en grandes empresas. Hoy es conocimiento práctico para cualquier persona que despliegue un backend en la nube, porque decisiones aparentemente pequeñas, como guardar las sesiones en memoria o exponer una base de datos sin réplicas, determinan si un producto sobrevive a su primer pico de tráfico.

Esta guía recorre el camino completo, desde una aplicación alojada en un único servidor hasta una arquitectura preparada para producción. Se abordan la elección de base de datos, el escalado vertical y horizontal, el balanceo de carga, los health checks, la eliminación de puntos únicos de fallo, el diseño de APIs con REST y GraphQL, los protocolos de transporte, la autenticación, la autorización y la seguridad. Los ejemplos usan Node.js 26, la rama LTS más reciente al momento de escribir, Express 5 y NGINX, aunque los conceptos son independientes de la tecnología.

El punto de partida: un único servidor

Casi todos los sistemas empiezan igual. Un usuario escribe un dominio en el navegador, el DNS lo traduce a una dirección IP y la petición llega a una única máquina que ejecuta la aplicación, sirve los archivos estáticos y, a menudo, aloja también la base de datos.

Un servidor mínimo en Express 5 que atiende un catálogo de productos podría verse así:

import express from "express";

const app = express();
app.use(express.json());

const productos = new Map([
    [1, { id: 1, nombre: "Teclado mecánico", precio: 89 }],
    [2, { id: 2, nombre: "Monitor 27 pulgadas", precio: 249 }],
]);

app.get("/api/productos/:id", (req, res) => {
    const producto = productos.get(Number(req.params.id));
    if (!producto)
        return res.status(404).json({ error: "Producto no encontrado" });
    res.json(producto);
});

app.listen(3000, () => console.log("Catálogo escuchando en el puerto 3000"));

Esta configuración tiene virtudes reales: es barata, fácil de desplegar y sencilla de depurar, porque todo ocurre en un mismo lugar. Para un prototipo o un producto con pocos usuarios suele ser la decisión correcta. El problema es que concentra todos los riesgos. Si el proceso se cae, el servicio desaparece; si la CPU se satura, todos los usuarios sufren; y si el disco falla, se pierden aplicación y datos a la vez.

El primer paso evolutivo casi siempre consiste en separar la base de datos en su propio servidor. Así cada componente puede dimensionarse por separado y la capa web puede crecer sin arrastrar consigo el almacenamiento.

Elegir base de datos: relacional, documental o de grafos

La base de datos es la pieza más difícil de cambiar una vez que el sistema está en producción, por eso conviene elegirla con criterio. Existen tres familias principales con fortalezas distintas.

Las bases de datos relacionales, como PostgreSQL o MySQL, organizan la información en tablas con esquemas definidos y relaciones explícitas. Ofrecen transacciones ACID, consultas potentes con SQL y garantías fuertes de consistencia. Son la opción por defecto para datos estructurados con relaciones claras, como pedidos, facturas o inventarios.

CREATE TABLE clientes (
  id         BIGSERIAL PRIMARY KEY,
  email      TEXT UNIQUE NOT NULL,
  creado_en  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE pedidos (
  id          BIGSERIAL PRIMARY KEY,
  cliente_id  BIGINT NOT NULL REFERENCES clientes(id),
  total       NUMERIC(10, 2) NOT NULL,
  estado      TEXT NOT NULL CHECK (estado IN ('pendiente', 'pagado', 'enviado'))
);

Las bases NoSQL agrupan modelos variados: documentales como MongoDB, clave-valor como Redis, o de columnas anchas como Cassandra. Sacrifican parte de la rigidez del esquema o de las garantías transaccionales a cambio de flexibilidad y facilidad para distribuirse entre muchas máquinas. Un documento puede contener toda la información que una pantalla necesita, evitando uniones costosas:

{
    "_id": "pedido_8812",
    "cliente": { "id": 431, "email": "[email protected]" },
    "lineas": [
        { "sku": "TEC-01", "cantidad": 1, "precio": 89 },
        { "sku": "MON-27", "cantidad": 2, "precio": 249 }
    ],
    "estado": "pagado"
}

Las bases de grafos, como Neo4j, modelan entidades como nodos y relaciones como aristas de primera clase. Brillan cuando la pregunta principal trata sobre conexiones: recomendaciones, redes sociales o detección de fraude. Encontrar amigos de amigos que compraron un producto es natural en Cypher:

MATCH (yo:Usuario {id: 431})-[:SIGUE]->(:Usuario)-[:SIGUE]->(sugerido:Usuario)
WHERE NOT (yo)-[:SIGUE]->(sugerido) AND sugerido <> yo
RETURN sugerido.nombre, count(*) AS conexiones
ORDER BY conexiones DESC
LIMIT 10;

La recomendación práctica es empezar con una base relacional salvo que exista una razón concreta para otra cosa. Muchas arquitecturas maduras terminan combinando varias: un motor relacional como fuente de verdad, Redis como caché y un índice de búsqueda o una base de grafos para casos específicos.

Escalado vertical frente a escalado horizontal

Cuando el servidor empieza a quedarse corto hay dos caminos. El escalado vertical consiste en dar más recursos a la misma máquina: más CPU, más memoria, discos más rápidos. Es la solución más simple porque no exige cambios en el código, y en la nube a menudo basta con elegir un tipo de instancia mayor.

Sus límites, sin embargo, son claros. Existe un techo físico y económico, ya que las máquinas más potentes cuestan desproporcionadamente más. Además, una sola máquina sigue siendo un único punto de fallo, y ampliarla suele requerir un reinicio con tiempo de inactividad.

El escalado horizontal añade más máquinas y reparte el trabajo entre ellas. Permite crecer casi sin límite, tolerar la caída de instancias individuales y ajustar la capacidad a la demanda añadiendo o retirando servidores. A cambio, introduce complejidad: hace falta un componente que distribuya el tráfico, la aplicación debe poder ejecutarse en varias copias a la vez y aparecen problemas propios de los sistemas distribuidos, como la consistencia de datos o la sincronización.

AspectoEscalado verticalEscalado horizontal
ImplementaciónSencilla, sin cambios de códigoRequiere balanceador y servicios sin estado
Límite de crecimientoTecho del hardware disponiblePrácticamente ilimitado
Tolerancia a fallosNinguna, una sola máquinaAlta, con varias instancias
Coste a gran escalaCrece de forma desproporcionadaCrece de forma más lineal
Tiempo de inactividad al ampliarHabitualEvitable

En la práctica ambos enfoques se combinan. Lo habitual es escalar verticalmente la base de datos hasta donde sea razonable, porque distribuirla es costoso, y recurrir al escalado horizontal con balanceador para la capa de aplicación, que es la más fácil de replicar.

Servidores sin estado como requisito para escalar

Para que varias copias de una aplicación puedan atender peticiones indistintamente, ninguna debe guardar información que las otras necesiten. Si un servidor almacena la sesión de un usuario en su memoria, la siguiente petición de ese usuario fallará cuando el balanceador la envíe a otra instancia.

La solución es externalizar el estado. Las sesiones pasan a un almacén compartido como Redis, los archivos subidos a un almacenamiento de objetos y cualquier caché que deba ser coherente a un servicio centralizado. Una alternativa muy extendida es usar tokens autocontenidos, que se tratarán más adelante, de forma que cada petición lleve consigo la información necesaria para identificar al usuario.

import { createClient } from "redis";

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

export async function guardarCarrito(usuarioId, carrito) {
    await redis.set(`carrito:${usuarioId}`, JSON.stringify(carrito), {
        EX: 60 * 60 * 24,
    });
}

export async function obtenerCarrito(usuarioId) {
    const datos = await redis.get(`carrito:${usuarioId}`);
    return datos ? JSON.parse(datos) : { lineas: [] };
}

Con el carrito en Redis, cualquier instancia puede leerlo o actualizarlo, y añadir o retirar servidores deja de afectar a los usuarios.

Balanceo de carga y sus algoritmos

El balanceador de carga es el componente que recibe todas las peticiones y las reparte entre las instancias disponibles. Puede ser software como NGINX o HAProxy, o un servicio gestionado del proveedor de nube. Además de distribuir el tráfico, suele terminar TLS, ocultar la topología interna y retirar automáticamente los servidores que fallan.

Los algoritmos de reparto más comunes son tres. Round robin envía cada petición a la siguiente instancia de la lista, de forma cíclica; es simple y funciona bien cuando todas las máquinas son iguales. Least connections elige la instancia con menos conexiones activas, útil cuando algunas peticiones tardan mucho más que otras. IP hash asigna siempre al mismo cliente a la misma instancia, lo que puede servir para afinidad de sesión, aunque reduce la uniformidad del reparto.

Una configuración de NGINX que reparte el tráfico entre tres réplicas del catálogo con el algoritmo de menos conexiones sería:

upstream catalogo {
    least_conn;
    server 10.0.1.11:3000 max_fails=3 fail_timeout=30s;
    server 10.0.1.12:3000 max_fails=3 fail_timeout=30s;
    server 10.0.1.13:3000 max_fails=3 fail_timeout=30s;
}

server {
    listen 443 ssl;
    server_name api.tienda.ejemplo;

    ssl_certificate     /etc/nginx/certs/tienda.pem;
    ssl_certificate_key /etc/nginx/certs/tienda.key;

    location /api/ {
        proxy_pass http://catalogo;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Los parámetros max_fails y fail_timeout implementan un control pasivo: si una instancia falla tres veces seguidas, NGINX deja de enviarle tráfico durante treinta segundos. Es una primera defensa, pero no sustituye a las comprobaciones activas de salud.

Health checks: detectar instancias enfermas

Un servidor puede estar encendido y aceptar conexiones sin ser capaz de atender peticiones, por ejemplo porque perdió la conexión con la base de datos o agotó la memoria. Los health checks permiten al balanceador o al orquestador preguntar periódicamente a cada instancia si está en condiciones de trabajar.

Conviene distinguir dos tipos. La comprobación de vida (liveness) indica si el proceso funciona o debe reiniciarse. La comprobación de disponibilidad (readiness) indica si la instancia puede recibir tráfico ahora mismo, teniendo en cuenta sus dependencias. Un endpoint de health check bien diseñado separa ambas cosas:

app.get("/salud/vida", (req, res) => {
    res.status(200).json({ estado: "vivo" });
});

app.get("/salud/disponible", async (req, res) => {
    try {
        await Promise.all([db.query("SELECT 1"), redis.ping()]);
        res.status(200).json({ estado: "disponible" });
    } catch (error) {
        res.status(503).json({
            estado: "no disponible",
            motivo: error.message,
        });
    }
});

Al consultarlo, una instancia sana responde con código 200 y una con problemas con 503, que el balanceador interpreta como señal para retirarla:

$ curl -i http://10.0.1.12:3000/salud/disponible
HTTP/1.1 503 Service Unavailable
Content-Type: application/json; charset=utf-8

{"estado":"no disponible","motivo":"connect ECONNREFUSED 10.0.2.5:5432"}

En Kubernetes, estas rutas se conectan con las sondas del contenedor:

livenessProbe:
    httpGet:
        path: /salud/vida
        port: 3000
    periodSeconds: 10
readinessProbe:
    httpGet:
        path: /salud/disponible
        port: 3000
    periodSeconds: 5
    failureThreshold: 3

Un error frecuente es incluir dependencias externas en la sonda de vida. Si la base de datos cae, todas las instancias fallarían la comprobación y el orquestador las reiniciaría en bucle, empeorando la situación. La sonda de vida debe verificar solo el propio proceso.

Eliminar puntos únicos de fallo

Un punto único de fallo es cualquier componente cuya caída detiene todo el sistema. En la arquitectura de un solo servidor lo es la máquina entera; tras añadir un balanceador, el propio balanceador puede convertirse en el nuevo punto débil si solo hay uno.

Identificarlos requiere recorrer el camino de una petición y preguntarse, en cada salto, qué ocurre si ese elemento desaparece. Las soluciones habituales son la redundancia y la conmutación automática:

ComponenteRiesgoMitigación habitual
Servidor de aplicaciónCaída de la instanciaVarias réplicas tras un balanceador
BalanceadorCaída del único balanceadorPar activo-pasivo con IP flotante o servicio gestionado
Base de datosPérdida del primarioRéplicas con conmutación automática
Zona de disponibilidadCorte en un centro de datosDespliegue en varias zonas
DNSFallo del proveedorProveedor con red anycast o proveedor secundario

En la base de datos, el patrón más común es la replicación primario-réplica. El primario recibe las escrituras y las réplicas copian los cambios y atienden lecturas; si el primario falla, una réplica se promueve. Esto también permite repartir la carga de lectura, que en muchas aplicaciones supera ampliamente a la de escritura.

La redundancia tiene un coste, por lo que no siempre se elimina cada punto de fallo. La decisión debe basarse en el impacto de una caída y en los objetivos de disponibilidad acordados con el negocio.

Principios de diseño de APIs

La API es el contrato entre el sistema y sus clientes, ya sean aplicaciones web, móviles u otros servicios. Una vez publicada, cambiarla sin romper a nadie es difícil, por lo que conviene diseñarla con cuidado desde el principio.

Varios principios aplican sin importar el estilo elegido. La consistencia hace que nombres, formatos de fecha, paginación y errores sigan las mismas reglas en todos los endpoints. La previsibilidad implica que un cliente pueda intuir cómo funciona un recurso nuevo a partir de los que ya conoce. El versionado permite evolucionar sin romper integraciones existentes, ya sea con un prefijo en la ruta como /v2/ o mediante cabeceras.

Los errores merecen un formato homogéneo y útil. Devolver solo un código 400 sin explicación obliga al cliente a adivinar; un cuerpo estructurado facilita la depuración:

{
    "error": {
        "codigo": "STOCK_INSUFICIENTE",
        "mensaje": "Solo quedan 2 unidades del producto MON-27",
        "campo": "lineas[1].cantidad"
    }
}

La idempotencia es otro concepto clave. Una operación idempotente produce el mismo resultado aunque se repita varias veces, algo esencial cuando una red inestable provoca reintentos. Las operaciones de creación sensibles, como cobros, suelen aceptar una clave de idempotencia para que un reintento no genere un segundo cargo.

Protocolos de comunicación entre cliente y servidor

HTTP es el protocolo dominante para APIs públicas. Su modelo de petición y respuesta encaja bien con operaciones sobre recursos, y su ecosistema de cachés, proxies y herramientas es enorme. HTTP/2 añadió multiplexación de varias peticiones sobre una conexión, y HTTP/3 traslada el transporte a QUIC, construido sobre UDP, para reducir la latencia en redes con pérdidas.

Cuando el servidor necesita enviar información al cliente sin que este pregunte, aparecen otras opciones. WebSockets abren un canal bidireccional persistente, adecuado para chats, juegos o paneles en tiempo real. Server-Sent Events ofrecen un flujo unidireccional del servidor al cliente sobre HTTP, más simple cuando solo se necesitan notificaciones.

Para la comunicación interna entre microservicios es común gRPC, que usa HTTP/2 y Protocol Buffers como formato binario. Es más eficiente que JSON y genera clientes tipados a partir de un contrato:

syntax = "proto3";

service Inventario {
  rpc ConsultarStock (ConsultaStock) returns (RespuestaStock);
}

message ConsultaStock {
  string sku = 1;
}

message RespuestaStock {
  string sku = 1;
  int32 disponibles = 2;
}

La elección depende del caso: HTTP con JSON para APIs públicas y navegadores, WebSockets o SSE para tiempo real, y gRPC para tráfico interno de alto volumen entre servicios controlados por el mismo equipo.

La capa de transporte: TCP y UDP

Por debajo de todos esos protocolos están los de transporte. TCP establece una conexión mediante un saludo en tres pasos, garantiza que los datos lleguen completos y en orden, retransmite lo que se pierde y regula el ritmo de envío para no saturar la red. Es la base de HTTP/1.1, HTTP/2, la mayoría de bases de datos y cualquier aplicación donde perder un byte sea inaceptable.

UDP, en cambio, envía datagramas sin establecer conexión ni garantizar entrega u orden. Esa ausencia de control lo hace más ligero y con menor latencia, por lo que se usa en videollamadas, streaming en directo, juegos en línea y DNS, donde un paquete tardío resulta inútil y es preferible descartarlo. QUIC demuestra que es posible construir fiabilidad propia sobre UDP evitando algunas limitaciones de TCP.

CaracterísticaTCPUDP
ConexiónOrientado a conexiónSin conexión
Entrega garantizadaSí, con retransmisionesNo
Orden de llegadaGarantizadoNo garantizado
LatenciaMayorMenor
Usos típicosWeb, APIs, bases de datos, correoVoz, video, juegos, DNS, QUIC

Entender esta diferencia ayuda a diagnosticar problemas reales. Una API lenta en redes móviles puede deberse al coste de establecer nuevas conexiones TCP y TLS, algo que se mitiga reutilizando conexiones o adoptando HTTP/3.

APIs REST bien construidas

REST organiza la API en torno a recursos identificados por URLs y manipulados con los verbos estándar de HTTP. Un diseño de APIs REST coherente usa sustantivos en plural para las colecciones y deja que el verbo exprese la acción:

MétodoRutaAcción
GET/v1/pedidosListar pedidos con paginación
GET/v1/pedidos/8812Obtener un pedido
POST/v1/pedidosCrear un pedido
PATCH/v1/pedidos/8812Modificar campos de un pedido
DELETE/v1/pedidos/8812Cancelar o eliminar un pedido

Los códigos de estado transmiten el resultado sin necesidad de leer el cuerpo: 200 para éxito, 201 cuando se crea un recurso, 204 cuando no hay contenido que devolver, 400 para peticiones mal formadas, 401 cuando falta autenticación, 403 cuando el usuario no tiene permiso, 404 para recursos inexistentes, 409 ante conflictos, 429 cuando se supera el límite de peticiones y 5xx para errores del servidor.

Una implementación en Express 5 aprovecha que esta versión propaga automáticamente los errores de los manejadores asíncronos, de modo que ya no hace falta envolver cada ruta con bloques try/catch para pasarlos a next, como ocurría en Express 4:

const router = express.Router();

router.get("/pedidos", async (req, res) => {
    const limite = Math.min(Number(req.query.limite) || 20, 100);
    const cursor = req.query.cursor ?? null;
    const { elementos, siguiente } = await repositorio.listarPedidos({
        limite,
        cursor,
    });
    res.json({ datos: elementos, paginacion: { siguiente } });
});

router.post("/pedidos", async (req, res) => {
    const pedido = await repositorio.crearPedido(req.body);
    res.status(201).location(`/v1/pedidos/${pedido.id}`).json(pedido);
});

app.use("/v1", router);

app.use((err, req, res, next) => {
    console.error(err);
    res.status(err.status ?? 500).json({
        error: { codigo: "ERROR_INTERNO", mensaje: "Error inesperado" },
    });
});

La paginación por cursor, mostrada en el ejemplo, escala mejor que la basada en desplazamiento numérico, porque no obliga a la base de datos a recorrer y descartar miles de filas cuando el cliente pide páginas avanzadas.

GraphQL y cuándo conviene

GraphQL propone un enfoque distinto: un único endpoint y un esquema tipado donde el cliente pide exactamente los campos que necesita. Resuelve dos problemas clásicos de REST, la sobrecarga de datos innecesarios y la necesidad de encadenar varias peticiones para componer una pantalla.

El esquema define tipos y operaciones disponibles:

type Cliente {
    id: ID!
    email: String!
    pedidos(ultimos: Int = 5): [Pedido!]!
}

type Pedido {
    id: ID!
    total: Float!
    estado: EstadoPedido!
}

enum EstadoPedido {
    PENDIENTE
    PAGADO
    ENVIADO
}

type Query {
    cliente(id: ID!): Cliente
}

Y el cliente compone la consulta según su necesidad:

query PanelCliente {
    cliente(id: "431") {
        email
        pedidos(ultimos: 3) {
            id
            total
            estado
        }
    }
}

Esa flexibilidad tiene contrapartidas. Las cachés HTTP tradicionales funcionan peor porque casi todo viaja como POST al mismo endpoint. Las consultas profundamente anidadas pueden disparar el problema N+1, en el que cada elemento de una lista provoca una consulta adicional a la base de datos, algo que se resuelve agrupando cargas con patrones como DataLoader. Además, conviene limitar la profundidad y la complejidad de las consultas para evitar que un cliente malicioso sature el servidor.

Como regla general, REST encaja bien en APIs públicas sencillas y con recursos estables, mientras que GraphQL aporta más valor cuando múltiples clientes, como web y móvil, necesitan combinaciones distintas de datos sobre un modelo rico y relacionado.

Autenticación: demostrar quién eres

La autenticación responde a la pregunta de quién está haciendo la petición. Existen varios enfoques. Las sesiones con cookie guardan un identificador en el navegador y el estado en el servidor o en un almacén compartido. Las claves de API identifican a sistemas o integraciones. OAuth 2.0 junto con OpenID Connect delega el inicio de sesión en un proveedor de identidad y es el estándar para inicio de sesión con terceros.

La autenticación basada en tokens con JWT es muy popular en sistemas distribuidos porque el token es autocontenido: incluye la identidad del usuario y una firma que cualquier servicio puede verificar sin consultar una base de datos central. Con la librería jose, la emisión y verificación en Node.js resultan concisas:

import { SignJWT, jwtVerify } from "jose";

const secreto = new TextEncoder().encode(process.env.JWT_SECRETO);

export async function emitirToken(usuario) {
    return new SignJWT({ rol: usuario.rol })
        .setProtectedHeader({ alg: "HS256" })
        .setSubject(String(usuario.id))
        .setIssuer("https://auth.tienda.ejemplo")
        .setAudience("api-tienda")
        .setIssuedAt()
        .setExpirationTime("15m")
        .sign(secreto);
}

export async function autenticar(req, res, next) {
    const [esquema, token] = (req.headers.authorization ?? "").split(" ");
    if (esquema !== "Bearer" || !token) {
        return res.status(401).json({ error: { codigo: "NO_AUTENTICADO" } });
    }
    try {
        const { payload } = await jwtVerify(token, secreto, {
            issuer: "https://auth.tienda.ejemplo",
            audience: "api-tienda",
        });
        req.usuario = { id: payload.sub, rol: payload.rol };
        next();
    } catch {
        res.status(401).json({ error: { codigo: "TOKEN_INVALIDO" } });
    }
}

Hay detalles importantes. Los tokens de acceso deben durar poco, unos minutos, y renovarse con un token de refresco que se pueda revocar. Nunca deben guardarse datos sensibles en el contenido del JWT, ya que solo está firmado, no cifrado, y cualquiera puede leerlo. En sistemas con varios servicios conviene usar firmas asimétricas, como RS256 o ES256, para que los servicios verifiquen con la clave pública sin poder emitir tokens.

Autorización: decidir qué puedes hacer

Una vez identificado el usuario, la autorización determina qué puede hacer. Confundir ambos conceptos es una fuente frecuente de vulnerabilidades: estar autenticado no implica tener permiso para todo.

El modelo más extendido es el control de acceso basado en roles (RBAC), donde cada usuario tiene uno o varios roles y cada rol agrupa permisos. Un middleware sencillo lo implementa así:

const permisosPorRol = {
    cliente: ["pedidos:leer_propios", "pedidos:crear"],
    soporte: ["pedidos:leer_todos"],
    admin: ["pedidos:leer_todos", "pedidos:crear", "pedidos:cancelar"],
};

export function requiere(permiso) {
    return (req, res, next) => {
        const permisos = permisosPorRol[req.usuario?.rol] ?? [];
        if (!permisos.includes(permiso)) {
            return res
                .status(403)
                .json({ error: { codigo: "PERMISO_DENEGADO" } });
        }
        next();
    };
}

router.delete(
    "/pedidos/:id",
    autenticar,
    requiere("pedidos:cancelar"),
    cancelarPedido
);

RBAC no basta cuando el permiso depende del propio recurso. Un cliente puede leer pedidos, pero solo los suyos. Si el código consulta el pedido por su identificador sin comprobar el propietario, cualquier usuario podría leer pedidos ajenos cambiando el número en la URL, una vulnerabilidad conocida como BOLA o IDOR y una de las más comunes en APIs. La comprobación debe hacerse siempre en el servidor:

router.get("/pedidos/:id", autenticar, async (req, res) => {
    const pedido = await repositorio.obtenerPedido(req.params.id);
    const esPropietario = pedido?.clienteId === req.usuario.id;
    const puedeVerTodos = (permisosPorRol[req.usuario.rol] ?? []).includes(
        "pedidos:leer_todos"
    );
    if (!pedido || (!esPropietario && !puedeVerTodos)) {
        return res
            .status(404)
            .json({ error: { codigo: "PEDIDO_NO_ENCONTRADO" } });
    }
    res.json(pedido);
});

Devolver 404 en lugar de 403 en este caso evita confirmar la existencia de recursos ajenos. Para reglas más complejas, que combinan atributos del usuario, del recurso y del contexto, existe el control basado en atributos (ABAC) y motores de políticas dedicados.

Seguridad de la API en producción

La seguridad no es una capa que se añade al final, sino un conjunto de prácticas repartidas por todo el sistema. Todo el tráfico debe ir cifrado con TLS, tanto el público como, idealmente, el interno entre servicios. Las entradas deben validarse siempre en el servidor, rechazando campos inesperados y tipos incorrectos, y las consultas a la base de datos deben usar parámetros en lugar de concatenar texto para evitar inyecciones SQL.

La limitación de peticiones protege frente a abusos, ataques de fuerza bruta y picos accidentales. NGINX permite aplicarla en el borde, antes de que el tráfico llegue a la aplicación:

limit_req_zone $binary_remote_addr zone=api_por_ip:10m rate=10r/s;

server {
    location /api/auth/ {
        limit_req zone=api_por_ip burst=5 nodelay;
        limit_req_status 429;
        proxy_pass http://catalogo;
    }
}

Los secretos, como claves de firma o credenciales de base de datos, nunca deben estar en el repositorio; corresponde guardarlos en un gestor de secretos o en variables de entorno inyectadas en el despliegue y rotarlos periódicamente. Las cabeceras de seguridad HTTP, una política CORS restrictiva y la actualización constante de dependencias completan la base. Finalmente, el registro de eventos de seguridad, como inicios de sesión fallidos o accesos denegados, permite detectar ataques en curso y analizar incidentes.

Errores comunes al diseñar sistemas

El primero es la optimización prematura. Construir una arquitectura de microservicios con colas, cachés distribuidas y varias bases de datos para un producto sin usuarios multiplica la complejidad operativa sin aportar valor. Un monolito bien estructurado, con una base de datos relacional, suele ser el mejor punto de partida.

El segundo es el opuesto: ignorar el estado en memoria hasta que llega el momento de escalar. Sesiones locales, archivos guardados en el disco de la instancia o tareas programadas que se ejecutan en cada réplica se convierten en errores difíciles de rastrear cuando aparecen varias copias de la aplicación.

El tercero es no medir. Sin métricas de latencia, tasas de error y uso de recursos, cualquier decisión de escalado es una apuesta. La observabilidad, con métricas, registros y trazas distribuidas, debe acompañar al sistema desde el principio.

El cuarto es tratar la autorización como un detalle del frontend. Ocultar un botón no impide que alguien llame directamente a la API. Toda comprobación de permisos debe vivir en el servidor y ejecutarse en cada petición.

El quinto es olvidar los fallos parciales. En un sistema distribuido, las llamadas entre servicios fallan o tardan más de lo esperado. Definir tiempos de espera, reintentos con espera exponencial y cortocircuitos evita que el fallo de un servicio arrastre a todos los demás.

Un ejercicio práctico: de mil a un millón de usuarios

Aplicar estos conceptos a un caso concreto ayuda a fijarlos. Imaginemos una tienda en línea que evoluciona con su base de usuarios.

Con unos pocos miles de usuarios, un único servidor con la aplicación y una base de datos gestionada en otra máquina basta. Las prioridades son copias de seguridad automáticas, TLS y un health check básico para recibir alertas.

Al alcanzar decenas de miles, la aplicación se vuelve sin estado, las sesiones pasan a Redis o a tokens, y se despliegan dos o tres réplicas tras un balanceador. Los archivos estáticos y las imágenes se sirven desde una CDN, lo que reduce drásticamente la carga sobre los servidores de origen.

Con cientos de miles, la base de datos se convierte en el cuello de botella. Se añaden réplicas de lectura, una caché para las consultas más frecuentes, como el catálogo de productos, e índices revisados a partir de las consultas lentas. Las tareas pesadas, como enviar correos o generar facturas, se trasladan a colas procesadas por trabajadores independientes.

Cerca del millón de usuarios, el despliegue se reparte entre varias zonas de disponibilidad, el escalado automático ajusta el número de réplicas según la carga y se evalúa dividir en servicios las áreas con necesidades muy distintas, como búsqueda o pagos. En cada etapa, la decisión se toma a partir de métricas reales y no de suposiciones.

Conclusiones

El diseño de sistemas consiste en tomar decisiones informadas sobre compromisos. No existe una arquitectura perfecta, sino arquitecturas adecuadas para un momento concreto, un volumen de tráfico y unos objetivos de disponibilidad. Empezar con un único servidor es razonable; lo importante es saber qué piezas habrá que cambiar cuando llegue el crecimiento.

Los conceptos recorridos forman una cadena coherente. Elegir bien la base de datos evita migraciones dolorosas, diseñar servicios sin estado habilita el escalado horizontal, el balanceador reparte la carga y los health checks retiran las instancias enfermas, mientras la redundancia elimina los puntos únicos de fallo. Sobre esa base, una API consistente, ya sea REST o GraphQL, ofrece a los clientes un contrato estable.

La seguridad atraviesa todas las capas: autenticación robusta con tokens de corta duración, autorización comprobada en el servidor para cada recurso, cifrado, validación de entradas y limitación de peticiones. Dominar estos fundamentos permite construir sistemas que no solo funcionan, sino que siguen funcionando cuando el tráfico crece y las cosas fallan.