Capítulo 7: Order Service
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:
- Domain: La lógica de negocio pura (la entidad Order).
- Repository: Acceso a datos y persistencia.
- Service: Orquesta las operaciones y coordina con otros componentes.
- API: Expone los endpoints HTTP.
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:
- Testing: Puedes usar un repositorio en memoria para tests.
- Flexibilidad: Cambiar de base de datos sin modificar la lógica de negocio.
- Separación de responsabilidades: Cada capa hace una cosa.
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
- Dominio con entidad Order y transiciones de estado válidas
- Repositorio para persistencia en PostgreSQL
- Servicio que orquesta operaciones y publica eventos
- API REST con validación de schemas
- Publicación de eventos OrderCreated al orquestador
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 →