← Volver al listado de tecnologías

API REST para CQRS

Por: SiempreListo
cqrsapiresttypescriptexpress

Capítulo 19: API REST para CQRS

REST (Representational State Transfer) es un estilo arquitectónico para diseñar APIs web. Cuando se combina con CQRS, el diseño de la API puede reflejar la separación entre operaciones de escritura (comandos) y lectura (queries).

Principios de Diseño

En CQRS, el método HTTP indica claramente si una operación es comando o query:

Esta tabla muestra el mapeo típico:

OperaciónHTTP MethodEndpointTipo CQRS
Crear pedidoPOST/ordersCommand
Agregar itemPOST/orders/:id/itemsCommand
Confirmar pedidoPOST/orders/:id/confirmCommand
Obtener pedidoGET/orders/:idQuery
Listar pedidosGET/ordersQuery
Buscar pedidosGET/orders/searchQuery

Router Principal

Express Router organiza endpoints en grupos. Podemos separar físicamente comandos y queries en routers diferentes, o unificarlos bajo un mismo recurso.

La separación física (/commands, /queries) es explícita pero poco REST. La unificación bajo /orders es más RESTful pero menos evidente.

// src/api/router.ts
import { Router } from 'express';
import { commandRouter } from './routes/commands';
import { queryRouter } from './routes/queries';

export const apiRouter = Router();

apiRouter.use('/commands', commandRouter);
apiRouter.use('/queries', queryRouter);
apiRouter.use('/orders', unifiedRouter); // Alternativa unificada

Endpoints de Comandos

Los comandos retornan 202 Accepted en lugar de 200/201 cuando el procesamiento es asíncrono. Esto indica que el servidor aceptó el comando pero el procesamiento puede continuar en background.

_links implementa HATEOAS (Hypermedia as the Engine of Application State): la respuesta incluye URLs para operaciones relacionadas, permitiendo navegación sin conocer la estructura de URLs.

// src/api/routes/commands.ts
import { Router, Request, Response } from 'express';
import { CommandBus } from '../../command/bus';
import { CreateOrderCommand } from '../../command/orders/create-order';
import { AddItemCommand } from '../../command/orders/add-item';
import { v4 as uuid } from 'uuid';

export const createCommandRouter = (commandBus: CommandBus): Router => {
  const router = Router();

  router.post('/orders', async (req: Request, res: Response) => {
    const orderId = uuid();
    const command = new CreateOrderCommand(orderId, req.body.customerId);

    await commandBus.dispatch(command);

    res.status(202).json({
      orderId,
      message: 'Order creation accepted',
      _links: { self: `/orders/${orderId}` }
    });
  });

  router.post('/orders/:id/items', async (req: Request, res: Response) => {
    const command = new AddItemCommand(
      req.params.id,
      req.body.productId,
      req.body.name,
      req.body.price,
      req.body.quantity
    );

    await commandBus.dispatch(command);
    res.status(202).json({ message: 'Item addition accepted' });
  });

  router.post('/orders/:id/confirm', async (req: Request, res: Response) => {
    const command = new ConfirmOrderCommand(req.params.id);
    await commandBus.dispatch(command);
    res.status(202).json({ message: 'Order confirmation accepted' });
  });

  return router;
};

Endpoints de Queries

Las queries usan GET con parámetros en query string para filtros y paginación. req.query contiene estos parámetros parseados automáticamente por Express.

Los parámetros se convierten explícitamente a tipos correctos: Number(req.query.page) porque query strings son siempre strings.

// src/api/routes/queries.ts
import { Router, Request, Response } from 'express';
import { QueryBus } from '../../query/bus';
import { GetOrderQuery } from '../../query/orders/get-order';
import { ListOrdersQuery } from '../../query/orders/list-orders';
import { SearchOrdersQuery } from '../../query/orders/search-orders';

export const createQueryRouter = (queryBus: QueryBus): Router => {
  const router = Router();

  router.get('/orders/:id', async (req: Request, res: Response) => {
    const query = new GetOrderQuery(req.params.id);
    const order = await queryBus.ask(query);

    if (!order) {
      return res.status(404).json({ error: 'Order not found' });
    }
    res.json(order);
  });

  router.get('/orders', async (req: Request, res: Response) => {
    const query = new ListOrdersQuery(
      req.query.customerId as string,
      Number(req.query.page) || 0,
      Number(req.query.limit) || 20
    );

    const result = await queryBus.ask(query);
    res.json(result);
  });

  router.get('/orders/search', async (req: Request, res: Response) => {
    const query = new SearchOrdersQuery({
      status: req.query.status as string,
      minTotal: Number(req.query.minTotal),
      maxTotal: Number(req.query.maxTotal),
      from: req.query.from as string,
      to: req.query.to as string
    });

    const results = await queryBus.ask(query);
    res.json(results);
  });

  return router;
};

Validación con Zod

Zod es una librería de validación de schemas con excelente soporte de TypeScript. Define schemas declarativos que validan y transforman datos.

safeParse retorna un objeto con success y data/error, permitiendo manejar errores sin excepciones.

Los middlewares de validación interceptan el request antes del handler, rechazando datos inválidos tempranamente.

// src/api/validators/order.validator.ts
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';

const createOrderSchema = z.object({
  customerId: z.string().uuid()
});

const addItemSchema = z.object({
  productId: z.string().uuid(),
  name: z.string().min(1),
  price: z.number().positive(),
  quantity: z.number().int().positive()
});

export const validateCreateOrder = (req: Request, res: Response, next: NextFunction) => {
  const result = createOrderSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ errors: result.error.flatten() });
  }
  next();
};

export const validateAddItem = (req: Request, res: Response, next: NextFunction) => {
  const result = addItemSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ errors: result.error.flatten() });
  }
  next();
};

Respuestas Asíncronas

En CQRS, los comandos pueden procesarse asíncronamente. Este middleware enriquece las respuestas de comandos con información sobre el procesamiento asíncrono.

pollUrl indica dónde consultar el estado de la operación. estimatedCompletion da una estimación del tiempo de procesamiento.

// src/api/middleware/async-response.ts
import { Request, Response, NextFunction } from 'express';

export const asyncCommandResponse = (req: Request, res: Response, next: NextFunction) => {
  const originalJson = res.json.bind(res);

  res.json = (data: any) => {
    if (req.method === 'POST' && res.statusCode === 202) {
      return originalJson({
        ...data,
        _async: true,
        pollUrl: data._links?.self,
        estimatedCompletion: '5s'
      });
    }
    return originalJson(data);
  };
  next();
};

Endpoint de Estado

Para operaciones asíncronas, es util tener un endpoint que muestre el estado actual y el historial de estados de un recurso.

// src/api/routes/status.ts
router.get('/orders/:id/status', async (req: Request, res: Response) => {
  const query = new GetOrderStatusQuery(req.params.id);
  const status = await queryBus.ask(query);

  res.json({
    orderId: req.params.id,
    status: status.current,
    lastUpdated: status.updatedAt,
    history: status.history
  });
});

Resumen

API REST para CQRS:

Glosario

REST (Representational State Transfer)

Definición: Estilo arquitectónico para APIs web que usa recursos identificados por URLs, métodos HTTP estándar, y representaciones de datos (JSON, XML).

Por qué es importante: Proporciona un estándar bien entendido para diseñar APIs. Los clientes pueden predecir el comportamiento basándose en métodos HTTP.

Ejemplo práctico: GET /orders/123 retorna el pedido, POST /orders crea uno nuevo, DELETE /orders/123 lo elimina. El método indica la intención.


HTTP Status 202 Accepted

Definición: Código de respuesta HTTP que indica que el request fue aceptado para procesamiento, pero el procesamiento no ha completado.

Por qué es importante: Comunica claramente que la operación es asíncrona. El cliente sabe que debe consultar después para ver el resultado.

Ejemplo práctico: POST /orders retorna 202 con {"orderId": "123", "pollUrl": "/orders/123"}. El cliente puede hacer GET al pollUrl para ver el estado.


HATEOAS

Definición: Hypermedia as the Engine of Application State. Principio REST donde las respuestas incluyen links a acciones relacionadas.

Por qué es importante: Los clientes pueden navegar la API sin conocer todas las URLs de antemano. La API se autodescribe.

Ejemplo práctico: GET /orders/123 retorna {"_links": {"items": "/orders/123/items", "confirm": "/orders/123/confirm"}}. El cliente descubre las acciones disponibles.


Express Router

Definición: Objeto de Express.js que agrupa rutas relacionadas. Permite modularizar la definición de endpoints.

Por qué es importante: Organiza el código en módulos por recurso o funcionalidad. Facilita testing y mantenimiento.

Ejemplo práctico: Router().get('/orders', handler) define rutas que luego se montan en el app principal con app.use('/api', router).


Zod

Definición: Librería de TypeScript para validación de schemas con inferencia de tipos. Define schemas declarativos que validan y transforman datos.

Por qué es importante: Valida datos en runtime mientras mantiene type-safety en compilación. Los tipos se infieren del schema, evitando duplicación.

Ejemplo práctico: z.object({price: z.number().positive()}) valida que price exista, sea número y sea positivo. TypeScript conoce el tipo del resultado.


Middleware

Definición: Función que se ejecuta entre la recepción del request y el handler final. Puede modificar request/response, validar, loguear, o terminar el request.

Por qué es importante: Permite reutilizar lógica transversal (autenticación, validación, logging) sin duplicarla en cada handler.

Ejemplo práctico: Un middleware de validación verifica el body antes del handler. Si es inválido, retorna 400 sin llegar al handler.


Query String

Definición: Parte de la URL después del ? que contiene pares clave=valor para pasar parámetros al servidor.

Por qué es importante: Permite pasar filtros, paginación y opciones a endpoints GET sin modificar la URL base del recurso.

Ejemplo práctico: GET /orders?customerId=123&page=2&limit=20 pasa tres parámetros que Express parsea en req.query.


Polling

Definición: Técnica donde el cliente consulta periódicamente un endpoint para verificar el estado de una operación asíncrona.

Por qué es importante: Permite manejar operaciones largas sin mantener conexiones abiertas. Simple de implementar tanto en cliente como servidor.

Ejemplo práctico: Después de POST /orders (202), el cliente hace GET /orders/123 cada 2 segundos hasta que status sea “completed”.


safeParse

Definición: Método de Zod que valida datos sin lanzar excepciones. Retorna un objeto con success y data/error.

Por qué es importante: Permite manejar errores de validación como flujo normal en lugar de excepciones, facilitando respuestas estructuradas.

Ejemplo práctico: const result = schema.safeParse(data); if (!result.success) return res.status(400).json(result.error) maneja el error sin try/catch.


← Capítulo 18: FastAPI | Capítulo 20: GraphQL →