Event-Driven Architecture with NATS · Parte 1

Core NATS: Subjects, Pub/Sub, Request/Reply y Queue Groups

Un recorrido práctico por Core NATS: cómo los subjects y los wildcards enrutan mensajes, por qué la entrega es at-most-once y cómo request/reply y los queue groups reemplazan a los balanceadores de carga en muchos patrones de microservicios.

10 min de lectura

Core NATS es la capa ligera de mensajería que está detrás de la mayoría de los despliegues de NATS. Enruta mensajes por subject, los distribuye mediante publish/subscribe, admite llamadas al estilo RPC a través de request/reply y balancea la carga de trabajo mediante queue groups, todo con entrega at-most-once y fire-and-forget y sin persistencia en el servidor.

Este último punto importa. Si un suscriptor está desconectado cuando se publica un mensaje, el mensaje se pierde. Core NATS es rápido y desacoplado, pero no ofrece durabilidad. Cuando necesitas replay, retención o reentrega garantizada, das el paso a JetStream (Parte 2).

Para investigar este artículo se consultó docs.nats.io el July 6, 2026.

TL;DR

  • Core NATS es pub/sub basado en interés: los mensajes se entregan solo a suscriptores activos cuyos subjects coinciden; si nadie está escuchando, el mensaje se descarta.
  • La entrega es best-effort, at-most-once: los mensajes se mantienen en memoria y nunca se escriben en disco.
  • Los subjects son cadenas jerárquicas separadas por tokens .; los wildcards (* = un token, > = uno o más tokens al final) se aplican al suscribirse, no al publicar.
  • Request/reply ofrece semántica de llamadas al estilo RPC sobre pub/sub asíncrono: el cliente publica una solicitud con un inbox de respuesta y se bloquea hasta que llega una respuesta o se agota el timeout.
  • Los queue groups balancean la carga entre los miembros de un mismo grupo; los suscriptores normales (fuera de un queue group) del mismo subject siguen recibiendo todos los mensajes.
  • Usa Core NATS para fan-out en tiempo real, RPC interno y pools de workers. Usa JetStream cuando los eventos deban sobrevivir al tiempo que un suscriptor esté desconectado.

Qué aprenderás aquí

  • Qué es Core NATS y qué no es
  • Cómo funcionan las jerarquías de subjects y los wildcards, incluidos los espacios de nombres reservados con $
  • La semántica de fan-out de publish/subscribe
  • Request/reply como RPC sobre mensajería
  • Queue groups para balancear la carga
  • Una guía para elegir patrones: síntoma → patrón → garantía de entrega
  • Cuándo pasar de Core NATS a JetStream

Para entender dónde encaja NATS en la arquitectura de un sistema en crecimiento, consulta Las piezas del diseño de software moderno y eficaz.

Qué es Core NATS (y qué no es)

NATS separa a los publicadores (quienes envían mensajes) de los suscriptores (quienes los reciben). El servidor enruta por nombre de subject: no hay un paso para crear topics en el broker ni se requiere un registro de esquemas a nivel de protocolo.

Características de Core NATS, según la documentación oficial:

PropiedadComportamiento de Core NATS
Garantía de entregaBest-effort, at-most-once
AlmacenamientoSolo en memoria; nunca se escribe en disco
Requisito de suscripciónBasado en interés: si no hay suscriptores activos, el mensaje se descarta
Fan-outTodos los suscriptores normales que coincidan reciben el mensaje
ReplayNo disponible

JetStream (Parte 2) añade persistencia, consumidores durables y entrega at-least-once sobre este transporte.

Subjects y wildcards

Un subject es una cadena separada por puntos que identifica un canal de mensajes. El token . crea la jerarquía:

orders.created.us-east
orders.created.eu-west
orders.shipped.us-east

Reglas de los wildcards

Los publicadores siempre usan subjects especificados por completo: no se usan wildcards al publicar. Los suscriptores usan wildcards para definir su ámbito de interés:

WildcardCoincide conEjemplo de suscripciónCoincideNo coincide
*Exactamente un tokenorders.*.us-eastorders.created.us-eastorders.created.eu-west
>Uno o más tokens al final; debe ser el últimoorders.>orders.created.us-eastbilling.invoice

Los wildcards se parecen a las expresiones regulares, pero son más restrictivos: * no puede coincidir con una parte de un token y > debe ser el último token.

Subjects reservados con $

Los subjects que empiezan con $ están reservados para uso del sistema: $SYS, $JS, $KV y otros. El tráfico de la aplicación no debe usar prefijos $, a menos que interactúes intencionalmente con un subsistema de NATS.

JetStream KV (presentado en la Parte 3) almacena claves bajo subjects como $KV.<bucket>.<key>. Las reglas de wildcards de Core NATS se aplican al observar un bucket:

Bucket:     app-objects
Key:        orders-v1.abc-123
Subject:    $KV.app-objects.orders-v1.abc-123

Watch all order keys:
  Subscribe: $KV.app-objects.orders-v1.>

En detalle: $KV está reservado, app-objects es el nombre del bucket, orders-v1 es un prefijo de clave (tipo de entidad) y > coincide con todos los ID de órdenes bajo ese prefijo.

Publish/Subscribe: fan-out fire-and-forget

Pub/sub es el patrón predeterminado de Core NATS. Un publicador, muchos suscriptores y ningún protocolo de confirmación.

import { connect, StringCodec } from "nats";

const nc = await connect({ servers: "nats://localhost:4222" });
const sc = StringCodec();

// Two independent consumers — both get every message
nc.subscribe("orders.created", {
  callback: (_err, msg) => console.log("analytics:", sc.decode(msg.data)),
});
nc.subscribe("orders.created", {
  callback: (_err, msg) => console.log("email:", sc.decode(msg.data)),
});

await nc.publish("orders.created", sc.encode(JSON.stringify({ id: "ord-42" })));
await nc.flush();

Punto clave: Si ninguno de los suscriptores está conectado cuando se publica el mensaje, este se descarta. No hay replay, cola de mensajes fallidos ni ack. Diseña tu sistema teniendo esto en cuenta, o usa JetStream.

                    ┌─────────────────────────────────────┐
                    │           NATS Server               │
                    │   (in-memory, no persistence)       │
                    └─────────────────────────────────────┘
                           ▲           ▲
           publish         │           │
    orders.created ────────┘           │
                                       │
     ┌─────────────────────────────────┼──────────────────┐
     │                                 │                  │
     ▼                                 ▼                  ▼
 [Analytics]                      [Email]           [no subscriber]
  plain sub                        plain sub          → message lost
  gets ALL                         gets ALL

Request/Reply: RPC sobre Pub/Sub

Request/reply no es un protocolo separado. Es pub/sub con una dirección de respuesta:

  1. El cliente publica una solicitud en un subject y establece un inbox único como subject de respuesta.
  2. Un servicio que responde recibe la solicitud y publica la respuesta en ese inbox.
  3. Las bibliotecas cliente encapsulan esto en una llamada bloqueante a request() con un timeout.
// Client — RPC-style call with timeout
msg, err := nc.Request("billing.invoice.get", []byte(`{"id":"inv-99"}`), time.Second)
if err == nats.ErrNoResponders {
    // No service listening — fail fast (503 when no_responders is enabled)
    log.Fatal("no billing service available")
}
fmt.Println(string(msg.Data))
// Responder
nc.Subscribe("billing.invoice.get", func(msg *nats.Msg) {
    // ... look up invoice ...
    msg.Respond([]byte(`{"id":"inv-99","total":1500}`))
})

Una distinción importante: Esto ofrece semántica de llamadas al estilo RPC. El transporte sigue siendo pub/sub asíncrono. Tú eres responsable de gestionar los timeouts, ErrNoResponders y la disponibilidad de los servicios que responden.

Varios servicios que responden pueden unirse a un queue group en el subject de solicitud para escalar horizontalmente: un miembro elegido al azar procesa cada solicitud.

Queue groups: balanceo de carga integrado

Los queue groups distribuyen el trabajo entre un conjunto de suscriptores que comparten el mismo nombre de cola. NATS entrega cada mensaje a exactamente un miembro del grupo, elegido al azar.

// Three workers sharing the same queue — each message goes to one worker
for (let i = 0; i < 3; i++) {
  nc.subscribe("jobs.render", {
    queue: "processors",
    callback: (_err, msg) => console.log(`worker-${i}:`, sc.decode(msg.data)),
  });
}

Detalle crítico: Los queue groups y los suscriptores normales coexisten en el mismo subject.

Subject: jobs.render

  [Worker A]  queue:"processors"  ─┐
  [Worker B]  queue:"processors"  ─┼─ each message → ONE queue member
  [Worker C]  queue:"processors"  ─┘

  [Monitor]   plain subscriber    ─── every message (for observability)

Si agregas un suscriptor normal para monitoreo junto con los workers de la cola, este sigue recibiendo todos los mensajes. Solo los miembros de un mismo queue group se reparten el trabajo.

Los queue groups son dinámicos: no requieren configuración en el servidor. Escala iniciando o deteniendo instancias de la aplicación. Usa drain antes de salir para que los mensajes en procesamiento se completen al reducir la escala.

Cuándo usar cada primitiva

SíntomaPatrón de Core NATSGarantía de entrega
Notificar a varios servicios cuando ocurre algoFan-out con pub/subAt-most-once; los suscriptores desconectados pierden mensajes
Llamar a un servicio y esperar una respuestaRequest/replyAt-most-once; timeout si no hay quien responda
Distribuir tareas entre N workersQueue groupAt-most-once; un worker por mensaje
No perder nunca un eventoNo Core NATS→ JetStream Parte 2
Reproducir eventos después de corregir un bugNo Core NATS→ Consumidores durables de JetStream

Implementación concreta: elegir un patrón

Este es un pequeño servicio que usa las tres primitivas para un flujo de órdenes:

import { connect, StringCodec } from "nats";

const nc = await connect({ servers: process.env.NATS_URL });
const sc = StringCodec();

// 1. Fan-out: notify analytics and email on every order
nc.subscribe("orders.created", {
  callback: (_err, msg) => publishToAnalytics(JSON.parse(sc.decode(msg.data))),
});

// 2. Load-balance: PDF generation across workers
nc.subscribe("orders.pdf", {
  queue: "pdf-workers",
  callback: async (_err, msg) => {
    const order = JSON.parse(sc.decode(msg.data));
    await renderPdf(order);
  },
});

// 3. RPC: look up inventory synchronously during checkout
nc.subscribe("inventory.check", {
  queue: "inventory-service",
  callback: (_err, msg) => {
    const sku = JSON.parse(sc.decode(msg.data));
    const available = lookupStock(sku);
    msg.respond(sc.encode(JSON.stringify({ available })));
  },
});

// Checkout flow
async function checkout(cart: Cart) {
  const stock = await nc.request(
    "inventory.check",
    sc.encode(JSON.stringify(cart.items)),
    { timeout: 2000 },
  );
  // ... charge, create order ...
  await nc.publish("orders.created", sc.encode(JSON.stringify({ id: "ord-99" })));
  await nc.publish("orders.pdf", sc.encode(JSON.stringify({ id: "ord-99" })));
}

Nota de diseño: Como Core NATS es at-most-once, publicar en orders.created después de un cobro exitoso es un punto de riesgo. Si ningún suscriptor está escuchando, se pierde la notificación. Para eventos críticos para el negocio, publica mediante JetStream.

Errores comunes

  1. Dar por sentada la entrega. Core NATS no almacena mensajes en búfer para suscriptores desconectados. Si esto importa, usa JetStream.
  2. Tratar los queue groups como exclusivos. Los suscriptores normales del mismo subject siguen recibiendo todos los mensajes.
  3. Usar wildcards al publicar. Los publicadores deben usar subjects concretos; los wildcards se usan solo al suscribirse.
  4. Confundir request/reply con HTTP. No hay una capa integrada de códigos de estado más allá de ErrNoResponders. Los timeouts, los reintentos y las estructuras de error son responsabilidad de la aplicación.
  5. Omitir la idempotencia. At-most-once también puede implicar duplicados en escenarios de falla en la capa de aplicación. Diseña los handlers para tolerar reintentos, incluso con Core NATS.

Core NATS vs JetStream: cuándo dar el paso

  Need                          Core NATS          JetStream
  ─────────────────────────────────────────────────────────────
  Live fan-out, all online       ✓                  optional
  RPC with timeout               ✓                  —
  Worker pool load balancing     ✓ (queue groups)   ✓ (work-queue streams)
  Subscriber was offline         ✗ message lost     ✓ replay from stream
  Guaranteed processing          ✗                  ✓ ack + redeliver
  Key-value shared state         ✗                  ✓ KV buckets (Part 3)

Siguiente en la serie: NATS JetStream: cuando los eventos necesitan sobrevivir al momento

Fuentes