← Volver al listado de tecnologías

Capítulo 7: Order Service

Por: SiempreListo
sagaorder-servicetypescriptmicroservicios

Capítulo 7: Order Service

“El punto de entrada de toda transacción distribuida”

Introducción

El Order Service es el servicio responsable de gestionar los pedidos. Es el punto de entrada de nuestra saga: cuando un cliente quiere comprar algo, la solicitud llega primero aquí.

Este servicio sigue una arquitectura por capas típica:

Estructura del Servicio

packages/order-service/
├── src/
│   ├── domain/
│   │   └── order.ts
│   ├── repository/
│   │   └── order-repository.ts
│   ├── service/
│   │   └── order-service.ts
│   ├── api/
│   │   └── routes.ts
│   └── index.ts
└── package.json

Configuración

packages/order-service/package.json

{
  "name": "@orderflow/order-service",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.js",
  "scripts": {
    "dev": "bun --watch src/index.ts",
    "build": "bun build src/index.ts --outdir dist --target node",
    "start": "bun dist/index.js"
  },
  "dependencies": {
    "@orderflow/shared": "workspace:*",
    "hono": "^4.4.0",
    "postgres": "^3.4.0",
    "zod": "^3.23.0"
  }
}

Dominio

La capa de dominio contiene la lógica de negocio sin dependencias de infraestructura. La clase Order es una entidad que representa un pedido con su estado y comportamiento.

Usamos el patrón State Machine (máquina de estados) para controlar las transiciones válidas del pedido. No cualquier cambio de estado es permitido; solo ciertas transiciones tienen sentido desde el punto de vista del negocio.

packages/order-service/src/domain/order.ts

import type { OrderItem, OrderStatus, CreateOrderInput } from '@orderflow/shared';

export class Order {
  constructor(
    public readonly id: string,
    public readonly customerId: string,
    public readonly items: OrderItem[],
    public readonly total: number,
    public status: OrderStatus,
    public readonly shippingAddress: CreateOrderInput['shippingAddress'],
    public readonly createdAt: Date,
    public updatedAt: Date
  ) {}

  static create(id: string, input: CreateOrderInput): Order {
    const total = input.items.reduce(
      (sum, item) => sum + item.unitPrice * item.quantity,
      0
    );

    return new Order(
      id,
      input.customerId,
      input.items,
      total,
      'pending',
      input.shippingAddress,
      new Date(),
      new Date()
    );
  }

  markAsStockReserved(): void {
    this.validateTransition('stock_reserved');
    this.status = 'stock_reserved';
    this.updatedAt = new Date();
  }

  markAsPaymentProcessed(): void {
    this.validateTransition('payment_processed');
    this.status = 'payment_processed';
    this.updatedAt = new Date();
  }

  complete(): void {
    this.validateTransition('completed');
    this.status = 'completed';
    this.updatedAt = new Date();
  }

  cancel(): void {
    if (this.status === 'completed') {
      throw new Error('Cannot cancel completed order');
    }
    this.status = 'cancelled';
    this.updatedAt = new Date();
  }

  private validateTransition(newStatus: OrderStatus): void {
    const validTransitions: Record<OrderStatus, OrderStatus[]> = {
      pending: ['stock_reserved', 'cancelled'],
      stock_reserved: ['payment_processed', 'cancelled'],
      payment_processed: ['completed', 'cancelled'],
      completed: [],
      cancelled: [],
      compensation_pending: ['cancelled']
    };

    if (!validTransitions[this.status].includes(newStatus)) {
      throw new Error(`Invalid transition from ${this.status} to ${newStatus}`);
    }
  }
}

Repositorio

El Repository Pattern (patrón repositorio) abstrae el acceso a datos. El servicio no sabe ni le importa si los datos vienen de PostgreSQL, MongoDB o un archivo. Solo interactúa con el repositorio.

Esto facilita:

packages/order-service/src/repository/order-repository.ts

import postgres from 'postgres';
import { Order } from '../domain/order.js';
import type { OrderStatus } from '@orderflow/shared';

export class OrderRepository {
  constructor(private sql: postgres.Sql) {}

  async save(order: Order): Promise<void> {
    await this.sql.begin(async (tx) => {
      await tx`
        INSERT INTO orders (id, customer_id, total, status, shipping_address, created_at, updated_at)
        VALUES (
          ${order.id},
          ${order.customerId},
          ${order.total},
          ${order.status},
          ${JSON.stringify(order.shippingAddress)},
          ${order.createdAt},
          ${order.updatedAt}
        )
        ON CONFLICT (id) DO UPDATE SET
          status = ${order.status},
          updated_at = ${order.updatedAt}
      `;

      for (const item of order.items) {
        await tx`
          INSERT INTO order_items (id, order_id, product_id, quantity, unit_price)
          VALUES (
            ${crypto.randomUUID()},
            ${order.id},
            ${item.productId},
            ${item.quantity},
            ${item.unitPrice}
          )
          ON CONFLICT DO NOTHING
        `;
      }
    });
  }

  async findById(id: string): Promise<Order | null> {
    const [orderRow] = await this.sql`
      SELECT id, customer_id, total, status, shipping_address, created_at, updated_at
      FROM orders WHERE id = ${id}
    `;

    if (!orderRow) return null;

    const items = await this.sql`
      SELECT product_id, quantity, unit_price
      FROM order_items WHERE order_id = ${id}
    `;

    return new Order(
      orderRow.id,
      orderRow.customer_id,
      items.map(i => ({
        productId: i.product_id,
        quantity: i.quantity,
        unitPrice: Number(i.unit_price)
      })),
      Number(orderRow.total),
      orderRow.status as OrderStatus,
      orderRow.shipping_address,
      orderRow.created_at,
      orderRow.updated_at
    );
  }

  async updateStatus(id: string, status: OrderStatus): Promise<void> {
    await this.sql`
      UPDATE orders SET status = ${status}, updated_at = NOW()
      WHERE id = ${id}
    `;
  }

  async findByCustomer(customerId: string): Promise<Order[]> {
    const orders = await this.sql`
      SELECT id, customer_id, total, status, shipping_address, created_at, updated_at
      FROM orders WHERE customer_id = ${customerId}
      ORDER BY created_at DESC
    `;

    return Promise.all(orders.map(async (row) => {
      const items = await this.sql`
        SELECT product_id, quantity, unit_price
        FROM order_items WHERE order_id = ${row.id}
      `;

      return new Order(
        row.id,
        row.customer_id,
        items.map(i => ({
          productId: i.product_id,
          quantity: i.quantity,
          unitPrice: Number(i.unit_price)
        })),
        Number(row.total),
        row.status as OrderStatus,
        row.shipping_address,
        row.created_at,
        row.updated_at
      );
    }));
  }
}

Servicio

La capa de servicio (también llamada capa de aplicación) orquesta las operaciones del dominio y coordina con otras partes del sistema. Aquí es donde se publican los eventos para notificar a otros servicios.

Nota cómo el servicio no conoce los detalles de HTTP ni de la base de datos; solo trabaja con abstracciones (repository, publishEvent).

packages/order-service/src/service/order-service.ts

import { Order } from '../domain/order.js';
import { OrderRepository } from '../repository/order-repository.js';
import type { CreateOrderInput, OrderCreatedEvent } from '@orderflow/shared';

export class OrderService {
  constructor(
    private repository: OrderRepository,
    private publishEvent: (event: OrderCreatedEvent) => Promise<void>
  ) {}

  async createOrder(input: CreateOrderInput): Promise<Order> {
    const orderId = crypto.randomUUID();
    const order = Order.create(orderId, input);

    await this.repository.save(order);

    await this.publishEvent({
      id: crypto.randomUUID(),
      type: 'OrderCreated',
      timestamp: new Date(),
      correlationId: orderId,
      payload: {
        orderId: order.id,
        customerId: order.customerId,
        items: order.items.map(i => ({
          productId: i.productId,
          quantity: i.quantity
        })),
        total: order.total
      }
    });

    return order;
  }

  async getOrder(orderId: string): Promise<Order | null> {
    return this.repository.findById(orderId);
  }

  async updateOrderStatus(orderId: string, status: Order['status']): Promise<void> {
    const order = await this.repository.findById(orderId);
    if (!order) throw new Error('Order not found');

    switch (status) {
      case 'stock_reserved':
        order.markAsStockReserved();
        break;
      case 'payment_processed':
        order.markAsPaymentProcessed();
        break;
      case 'completed':
        order.complete();
        break;
      case 'cancelled':
        order.cancel();
        break;
    }

    await this.repository.save(order);
  }

  async cancelOrder(orderId: string): Promise<void> {
    const order = await this.repository.findById(orderId);
    if (!order) throw new Error('Order not found');

    order.cancel();
    await this.repository.save(order);
  }
}

API

La capa de API (o presentación) expone los endpoints HTTP. Usamos Hono, un framework web ligero y rápido.

zValidator integra Zod con Hono para validar automáticamente los datos de entrada. Si los datos no cumplen el esquema, la solicitud se rechaza antes de llegar al servicio.

packages/order-service/src/api/routes.ts

import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { CreateOrderSchema } from '@orderflow/shared';
import { OrderService } from '../service/order-service.js';

export function createRoutes(orderService: OrderService): Hono {
  const app = new Hono();

  app.post('/orders', zValidator('json', CreateOrderSchema), async (c) => {
    const input = c.req.valid('json');
    const order = await orderService.createOrder(input);

    return c.json({
      id: order.id,
      status: order.status,
      total: order.total,
      createdAt: order.createdAt
    }, 201);
  });

  app.get('/orders/:id', async (c) => {
    const order = await orderService.getOrder(c.req.param('id'));

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

    return c.json({
      id: order.id,
      customerId: order.customerId,
      items: order.items,
      total: order.total,
      status: order.status,
      shippingAddress: order.shippingAddress,
      createdAt: order.createdAt,
      updatedAt: order.updatedAt
    });
  });

  app.patch('/orders/:id/status', async (c) => {
    const { status } = await c.req.json();
    await orderService.updateOrderStatus(c.req.param('id'), status);
    return c.json({ success: true });
  });

  app.post('/orders/:id/cancel', async (c) => {
    await orderService.cancelOrder(c.req.param('id'));
    return c.json({ success: true });
  });

  app.get('/health', (c) => c.json({ status: 'healthy' }));

  return app;
}

Entry Point

packages/order-service/src/index.ts

import { serve } from '@hono/node-server';
import postgres from 'postgres';
import { OrderRepository } from './repository/order-repository.js';
import { OrderService } from './service/order-service.js';
import { createRoutes } from './api/routes.js';

const DATABASE_URL = process.env.DATABASE_URL || 'postgres://orderflow:orderflow@localhost:5432/orderflow';
const PORT = parseInt(process.env.PORT || '3000');
const ORCHESTRATOR_URL = process.env.ORCHESTRATOR_URL || 'http://localhost:3000';

const sql = postgres(DATABASE_URL);
const repository = new OrderRepository(sql);

const publishEvent = async (event: unknown) => {
  await fetch(`${ORCHESTRATOR_URL}/events`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(event)
  });
};

const orderService = new OrderService(repository, publishEvent);
const app = createRoutes(orderService);

console.log(`Order Service running on port ${PORT}`);
serve({ fetch: app.fetch, port: PORT });

Resumen

Glosario

Entidad (de Dominio)

Definición: Objeto del dominio que tiene una identidad única que lo distingue de otros objetos, aunque sus atributos sean idénticos.

Por qué es importante: Las entidades son el corazón de la lógica de negocio. Encapsulan tanto datos como comportamiento, manteniendo las reglas de negocio en un solo lugar.

Ejemplo práctico: Dos pedidos con los mismos productos y cliente son diferentes entidades si tienen diferente id. La entidad Order contiene métodos como markAsStockReserved() que encapsulan las reglas de transición de estado.


State Machine (Máquina de Estados)

Definición: Modelo computacional donde un objeto puede estar en un número finito de estados, y las transiciones entre estados siguen reglas predefinidas.

Por qué es importante: Hace explícitas las transiciones válidas, previniendo estados inválidos. Si está en estado “completado”, no puede volver a “pendiente”.

Ejemplo práctico: Un pedido puede ir de pending a stock_reserved, pero no directamente a completed. El método validateTransition() rechaza transiciones inválidas.


Repository Pattern

Definición: Patrón de diseño que abstrae el acceso a datos detrás de una interfaz similar a una colección, ocultando los detalles de persistencia.

Por qué es importante: Desacopla la lógica de negocio de la tecnología de persistencia. Facilita testing con repositorios mock y permite cambiar de base de datos sin afectar el dominio.

Ejemplo práctico: OrderRepository.findById(id) devuelve un Order. El servicio no sabe si viene de PostgreSQL, MongoDB o memoria. Solo sabe que le llega un objeto del dominio.


Capa de Servicio

Definición: Capa que define los límites de la aplicación y orquesta la ejecución de las operaciones del dominio, coordinando múltiples componentes.

Por qué es importante: Encapsula los casos de uso de la aplicación. Es el punto de entrada desde la API y el lugar donde se coordina con sistemas externos (eventos, otros servicios).

Ejemplo práctico: OrderService.createOrder() crea la entidad Order, la persiste via repository, y publica el evento OrderCreated. Orquesta sin conocer detalles de HTTP o SQL.


API REST

Definición: Estilo arquitectónico para diseñar servicios web donde los recursos se identifican por URLs y se manipulan usando métodos HTTP estándar (GET, POST, PUT, DELETE).

Por qué es importante: Es un estándar ampliamente adoptado que facilita la interoperabilidad. Cualquier cliente HTTP puede consumir el servicio.

Ejemplo práctico: POST /orders crea un pedido, GET /orders/:id obtiene un pedido, PATCH /orders/:id/status actualiza el estado. Los verbos HTTP indican la operación.


Hono

Definición: Framework web minimalista para JavaScript/TypeScript, optimizado para Edge runtimes pero que funciona en Node.js y Bun.

Por qué es importante: Es ligero, rápido y tiene excelente integración con TypeScript. La API es simple e intuitiva.

Ejemplo práctico: app.post('/orders', handler) define una ruta. Hono parsea automáticamente JSON, maneja errores, y facilita la respuesta con c.json().


Validación de Entrada

Definición: Proceso de verificar que los datos recibidos de fuentes externas cumplen con el formato y las restricciones esperadas antes de procesarlos.

Por qué es importante: Previene errores, ataques de inyección, y datos corruptos. Nunca confíes en datos externos.

Ejemplo práctico: zValidator('json', CreateOrderSchema) valida que el body de la solicitud tenga customerId (UUID válido), items (array no vacío), etc. Si falla, responde 400 sin llegar al servicio.


UPSERT (ON CONFLICT)

Definición: Operación de base de datos que inserta un registro si no existe, o lo actualiza si ya existe. Combina INSERT y UPDATE en una sola operación.

Por qué es importante: Permite operaciones idempotentes a nivel de base de datos. Ejecutar la misma operación múltiples veces produce el mismo resultado.

Ejemplo práctico: INSERT INTO orders (...) ON CONFLICT (id) DO UPDATE SET status = ... inserta el pedido si es nuevo, o actualiza su estado si ya existe. Útil para reintentos.


Publicación de Eventos

Definición: Acción de enviar un mensaje (evento) a un sistema de mensajería para notificar que algo ocurrió, sin esperar respuesta.

Por qué es importante: Desacopla al productor del consumidor. Order Service no necesita saber quién consume OrderCreated; solo lo publica y sigue.

Ejemplo práctico: Después de crear el pedido, publishEvent({ type: 'OrderCreated', ... }) envía el evento al orquestador. El servicio no espera confirmación del procesamiento.


Correlation ID

Definición: Identificador único que se propaga a través de todas las operaciones relacionadas con una solicitud, permitiendo trazar el flujo completo.

Por qué es importante: En sistemas distribuidos, una solicitud de usuario atraviesa múltiples servicios. El correlation ID permite encontrar todos los logs y eventos relacionados.

Ejemplo práctico: El evento OrderCreated incluye correlationId: orderId. Todos los eventos subsiguientes (StockReserved, PaymentProcessed) usan el mismo correlationId para poder rastrearse juntos.


← Capítulo 6: Setup Microservicios | Capítulo 8: Inventory Service →