Este proyecto combina dos patrones:
CQRS (Command Query Responsibility Segregation): separa estrictamente las operaciones que mutan el estado (Commands) de las que solo leen (Queries). Cada una tiene su propio flujo y nunca se mezclan.
Event-Driven: cuando un Command completa su acción, publica un evento en el bus. Múltiples handlers reaccionan de forma independiente sin que el Command los conozca.
┌─────────────────────────────────────────────────────────────┐
│ REQUEST HTTP │
│ ↓ │
│ POST /inventory/items → CreateItemHandler (Command) │
│ ↓ │
│ muta la base de datos │
│ ↓ │
│ eventBus.publish('item.created') │
│ ↓ │
│ ┌─────────────────────────┐ │
│ │ EventLogHandler │ ← persiste evento en DB
│ │ ItemCreatedAuditHandler│ ← log en consola
│ └─────────────────────────┘ │
│ │
│ GET /inventory/items → GetAllItemsHandler (Query) │
│ ↓ │
│ solo lee, nunca muta │
│ nunca publica eventos │
└─────────────────────────────────────────────────────────────┘
node-fastify-cqrs/
├── src/
│ ├── server.js # Punto de entrada + wiring
│ ├── commands/
│ │ └── handlers/
│ │ └── index.js # CreateItem, UpdateStock, UpdateItem, DeleteItem
│ ├── queries/
│ │ └── handlers/
│ │ └── index.js # GetAllItems, GetItemById, GetLowStock, GetMovements, GetEventLog
│ ├── events/
│ │ ├── bus/
│ │ │ └── EventBus.js # Bus de eventos (EventEmitter)
│ │ └── handlers/
│ │ ├── index.js # eventLogHandler, stockAlertHandler, auditHandlers
│ │ └── register.js # Registra handlers en el bus al arrancar
│ ├── infrastructure/
│ │ ├── database/
│ │ │ └── sqlite.js # SQLite nativo Node 22
│ │ └── repositories/
│ │ └── ItemRepository.js # Acceso a datos: items, movements, event_log
│ └── interfaces/
│ ├── routes/
│ │ └── inventory.js # Rutas Fastify — GET=queries, POST/PUT/DELETE=commands
│ └── middleware/
│ └── errorHandler.js
| Tabla | Propósito |
|---|---|
items |
Catálogo de ítems del inventario |
stock_movements |
Historial de cada entrada/salida/ajuste de stock |
event_log |
Registro persistente de todos los eventos del bus |
- Node.js 22+
Verifica con:
node --version
Usa el módulo SQLite nativo de Node 22 (
node:sqlite). No necesitas drivers adicionales.
npm install# Modo desarrollo (reinicia al guardar cambios)
npm run dev
# O modo normal
npm startEl servidor arranca en: http://localhost:3001
La base de datos
inventory.dbse crea automáticamente al primer arranque.
GET http://localhost:3001/
Respuesta:
{
"status": "ok",
"project": "Node - Fastify CQRS + Event-Driven",
"architecture": "CQRS + EventEmitter Bus"
}En la consola del servidor verás:
✅ [EventBus] 4 handlers registrados
🚀 Servidor CQRS corriendo en http://localhost:3001
📡 Bus de eventos activo
http://localhost:3001
POST /inventory/items
{
"sku": "LAP-001",
"name": "Laptop Pro 15",
"description": "Laptop de alto rendimiento",
"quantity": 10,
"minStock": 5,
"unitPrice": 25999.99,
"category": "electronica"
}En consola verás: 📦 [AUDIT] Nuevo ítem creado: "Laptop Pro 15" | SKU: LAP-001
PUT /inventory/items/{id}/stock
{
"type": "in",
"quantity": 20,
"reason": "Reposición de mercancía"
}PUT /inventory/items/{id}/stock
{
"type": "out",
"quantity": 8,
"reason": "Venta al cliente #1042"
}Si el stock baja del minStock, verás en consola:
⚠️ [STOCK ALERT] "Laptop Pro 15" (SKU: LAP-001) | Stock actual: 2 | Mínimo: 5
PUT /inventory/items/{id}/stock
{
"type": "adjust",
"quantity": 15,
"reason": "Ajuste tras conteo físico"
}Valores válidos para type: "in" · "out" · "adjust"
PUT /inventory/items/{id}
{
"name": "Laptop Pro 15 v2",
"unitPrice": 23999.99,
"minStock": 8
}Todos los campos son opcionales.
DELETE /inventory/items/{id}
GET /inventory/items
Query params opcionales:
category→ filtra por categoríalowStock=true→ solo los que tienen stock bajo o en mínimoskip/take→ paginación
GET /inventory/items
GET /inventory/items?category=electronica
GET /inventory/items?lowStock=true
GET /inventory/items?skip=0&take=10
GET /inventory/items/{id}
GET /inventory/items/low-stock
Devuelve todos los ítems donde quantity <= minStock.
GET /inventory/items/{id}/movements
Muestra el historial completo de entradas, salidas y ajustes de ese ítem.
GET /inventory/events/log
Query params:
limit→ cantidad máxima de eventos (default 50)
Muestra todos los eventos que han pasado por el bus, en orden cronológico descendente. Verás item.created, stock.updated, item.deleted, etc. con su payload completo.
POST /inventory/items— crea un ítem conquantity: 8yminStock: 5GET /inventory/items— confirma que aparece conisLowStock: falsePUT /inventory/items/{id}/stockcontype: "out", quantity: 4— baja a 4 (bajo del mínimo)- Revisa la consola — verás el
⚠️ STOCK ALERT GET /inventory/items/low-stock— aparece en la lista de stock bajoPUT /inventory/items/{id}/stockcontype: "in", quantity: 20— repones stockGET /inventory/items/{id}/movements— ves el historial de los 2 movimientosGET /inventory/events/log— ves todos los eventos:item.created,stock.updatedx2
| Escenario | Request | Error esperado |
|---|---|---|
| SKU duplicado | POST con el mismo sku | 409 |
| Stock insuficiente | type: "out" con más quantity que stock | 409 |
| Ítem no existe | GET /inventory/items/id-falso | 404 |
| type inválido | PUT stock con type: "entrada" | 422 |
| Campos faltantes | POST sin sku o category | 422 |
Guarda como n2-inventory.postman_collection.json:
{
"info": {
"name": "Node - Fastify CQRS + Event-Driven",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{ "key": "base_url", "value": "http://localhost:3001" },
{ "key": "item_id", "value": "" }
],
"item": [
{
"name": "Health Check",
"request": { "method": "GET", "url": "{{base_url}}/" }
},
{
"name": "Commands",
"item": [
{
"name": "Create Item",
"event": [
{
"listen": "test",
"script": {
"exec": [
"const r = pm.response.json(); if(r.id) pm.collectionVariables.set('item_id', r.id);"
]
}
}
],
"request": {
"method": "POST",
"url": "{{base_url}}/inventory/items",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\"sku\": \"LAP-001\", \"name\": \"Laptop Pro\", \"quantity\": 10, \"minStock\": 5, \"unitPrice\": 25999.99, \"category\": \"electronica\"}"
}
}
},
{
"name": "Stock IN",
"request": {
"method": "PUT",
"url": "{{base_url}}/inventory/items/{{item_id}}/stock",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\"type\": \"in\", \"quantity\": 20, \"reason\": \"Reposicion\"}"
}
}
},
{
"name": "Stock OUT",
"request": {
"method": "PUT",
"url": "{{base_url}}/inventory/items/{{item_id}}/stock",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\"type\": \"out\", \"quantity\": 8, \"reason\": \"Venta al cliente\"}"
}
}
},
{
"name": "Stock ADJUST",
"request": {
"method": "PUT",
"url": "{{base_url}}/inventory/items/{{item_id}}/stock",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\"type\": \"adjust\", \"quantity\": 15, \"reason\": \"Conteo fisico\"}"
}
}
},
{
"name": "Update Item",
"request": {
"method": "PUT",
"url": "{{base_url}}/inventory/items/{{item_id}}",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\"unitPrice\": 23999.99, \"minStock\": 8}"
}
}
},
{
"name": "Delete Item",
"request": {
"method": "DELETE",
"url": "{{base_url}}/inventory/items/{{item_id}}"
}
}
]
},
{
"name": "Queries",
"item": [
{
"name": "List All Items",
"request": { "method": "GET", "url": "{{base_url}}/inventory/items" }
},
{
"name": "Filter by Category",
"request": {
"method": "GET",
"url": "{{base_url}}/inventory/items?category=electronica"
}
},
{
"name": "Get Item",
"request": {
"method": "GET",
"url": "{{base_url}}/inventory/items/{{item_id}}"
}
},
{
"name": "Low Stock Items",
"request": {
"method": "GET",
"url": "{{base_url}}/inventory/items/low-stock"
}
},
{
"name": "Stock Movements",
"request": {
"method": "GET",
"url": "{{base_url}}/inventory/items/{{item_id}}/movements"
}
},
{
"name": "Event Log",
"request": {
"method": "GET",
"url": "{{base_url}}/inventory/events/log"
}
}
]
}
]
}Este proyecto está desplegado como un Web Service en Render.
-
En el dashboard de Render, crea un nuevo Web Service y conecta el repositorio.
-
Configura el servicio:
Campo Valor Environment NodeBuild Command npm installStart Command npm start -
No se requieren variables de entorno. La base de datos
inventory.dbse crea automáticamente al primer arranque en el filesystem del contenedor.
El plan gratuito de Render usa un filesystem efímero: el log de eventos y el inventario se reinician con cada nuevo deploy. Para producción real se recomienda una base de datos externa.
- Una vez desplegado, copia la URL pública (ej.
https://node-fastify-cqrs.onrender.com) y pégala en el panel de ajustes de API Explorer para apuntar al entorno de producción.